From 15c58522ef74ccd144e2b42f7501f5c8b444106d Mon Sep 17 00:00:00 2001 From: Anatolii Grynchuk Date: Wed, 6 May 2026 01:15:59 +0300 Subject: [PATCH] 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> --- AGENTS.md | 167 ++++++++++++++ HrynCo.DAL.Abstract/Entities/Entity.cs | 50 +++- HrynCo.DAL.Abstract/Entities/IEntity.cs | 12 - HrynCo.DAL.Abstract/IUnitOfWork.cs | 4 +- HrynCo.DAL.Abstract/PagedResult.cs | 9 + HrynCo.DAL.EF/Converters/UtcValueConverter.cs | 6 - HrynCo.DAL.EF/Core/BaseDbContext.cs | 76 ------ HrynCo.DAL.EF/Core/BaseEfRepository.cs | 218 ++++++++++++++++++ HrynCo.DAL.EF/Core/BaseRepository.cs | 25 ++ HrynCo.DAL.EF/Core/EfRepository.cs | 46 ---- HrynCo.DAL.EF/Core/EfTransactionAdapter.cs | 18 +- HrynCo.DAL.EF/Core/EfUnitOfWork.cs | 27 +-- HrynCo.DAL.EF/Core/IEfRepository.cs | 53 +++++ .../UnexpectedEntityStateException.cs | 11 - README.md | 207 +++++++++++++++++ 15 files changed, 750 insertions(+), 179 deletions(-) create mode 100644 AGENTS.md delete mode 100644 HrynCo.DAL.Abstract/Entities/IEntity.cs create mode 100644 HrynCo.DAL.Abstract/PagedResult.cs delete mode 100644 HrynCo.DAL.EF/Converters/UtcValueConverter.cs delete mode 100644 HrynCo.DAL.EF/Core/BaseDbContext.cs create mode 100644 HrynCo.DAL.EF/Core/BaseEfRepository.cs create mode 100644 HrynCo.DAL.EF/Core/BaseRepository.cs delete mode 100644 HrynCo.DAL.EF/Core/EfRepository.cs create mode 100644 HrynCo.DAL.EF/Core/IEfRepository.cs delete mode 100644 HrynCo.DAL.EF/Exceptions/UnexpectedEntityStateException.cs create mode 100644 README.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4e5d0b9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,167 @@ +# 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: + - 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. + +### 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 +- YouTrack auth + reachability +- 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: + - set YouTrack state to `In Progress` + - 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 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 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. +10. Delivery and release manager handles commit, push, PRs, merge, and TeamCity publish validation. + +### Task rules + +- 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. + +## 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 +- 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` and `main` match their remotes +- leave the repository in a clean end state + +### Spent Time + +- 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 + +- 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 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, and final line `Ref: IT-`. + +## 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. diff --git a/HrynCo.DAL.Abstract/Entities/Entity.cs b/HrynCo.DAL.Abstract/Entities/Entity.cs index 97777fe..63929bb 100644 --- a/HrynCo.DAL.Abstract/Entities/Entity.cs +++ b/HrynCo.DAL.Abstract/Entities/Entity.cs @@ -1,9 +1,47 @@ namespace HrynCo.DAL.Abstract.Entities; -[Serializable] -public abstract class Entity : IEntity where TId : struct +public interface IEntity { + DateTimeOffset Created { get; set; } + object Id { get; set; } + DateTimeOffset? Updated { get; set; } +} + +public interface IEntity : IEntity where TId : struct +{ + new TId Id { get; set; } +} + +[Serializable] +public class Entity : IEntity where TId : struct +{ + protected Entity() + { + } + + public Entity(TId id) + { + Id = id; + } + 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? Updated { get; set; } } @@ -17,7 +55,13 @@ public abstract class Entity : Entity } protected Entity(Guid id) + : base(id) { - Id = id; } } + +[Serializable] +public abstract class NamedEntity : Entity +{ + public required string Name { get; set; } +} diff --git a/HrynCo.DAL.Abstract/Entities/IEntity.cs b/HrynCo.DAL.Abstract/Entities/IEntity.cs deleted file mode 100644 index 33ba1d6..0000000 --- a/HrynCo.DAL.Abstract/Entities/IEntity.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace HrynCo.DAL.Abstract.Entities; - -public interface IEntity -{ - DateTimeOffset Created { get; set; } - DateTimeOffset? Updated { get; set; } -} - -public interface IEntity : IEntity where TId : struct -{ - TId Id { get; set; } -} diff --git a/HrynCo.DAL.Abstract/IUnitOfWork.cs b/HrynCo.DAL.Abstract/IUnitOfWork.cs index aebb603..a1c5aa8 100644 --- a/HrynCo.DAL.Abstract/IUnitOfWork.cs +++ b/HrynCo.DAL.Abstract/IUnitOfWork.cs @@ -1,8 +1,10 @@ namespace HrynCo.DAL.Abstract; +using System.Threading; +using System.Threading.Tasks; + public interface IUnitOfWork { - Task SaveChangesAsync(CancellationToken cancellationToken = default); Task BeginTransactionAsync(CancellationToken cancellationToken = default); ITransaction? GetCurrentTransaction(); diff --git a/HrynCo.DAL.Abstract/PagedResult.cs b/HrynCo.DAL.Abstract/PagedResult.cs new file mode 100644 index 0000000..9c2d930 --- /dev/null +++ b/HrynCo.DAL.Abstract/PagedResult.cs @@ -0,0 +1,9 @@ +namespace HrynCo.DAL.Abstract; + +public sealed class PagedResult +{ + public required IReadOnlyList Items { get; init; } + public required int Page { get; init; } + public required int PageSize { get; init; } + public required int TotalCount { get; init; } +} diff --git a/HrynCo.DAL.EF/Converters/UtcValueConverter.cs b/HrynCo.DAL.EF/Converters/UtcValueConverter.cs deleted file mode 100644 index 8f2472c..0000000 --- a/HrynCo.DAL.EF/Converters/UtcValueConverter.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace HrynCo.DAL.EF.Converters; - -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -internal class UtcValueConverter() - : ValueConverter(v => v, v => DateTime.SpecifyKind(v, DateTimeKind.Utc)); diff --git a/HrynCo.DAL.EF/Core/BaseDbContext.cs b/HrynCo.DAL.EF/Core/BaseDbContext.cs deleted file mode 100644 index 44d76c8..0000000 --- a/HrynCo.DAL.EF/Core/BaseDbContext.cs +++ /dev/null @@ -1,76 +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 DateTimeOffset UtcNow => _clock.UtcNow; - - public override int SaveChanges() - { - ApplyTimestamps(); - return base.SaveChanges(); - } - - public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) - { - ApplyTimestamps(); - return await base.SaveChangesAsync(cancellationToken); - } - - private void ApplyTimestamps() - { - DateTimeOffset now = _clock.UtcNow; - - foreach (EntityEntry entry in ChangeTracker.Entries()) - { - 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().HaveConversion(); - } - - 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); - } -} diff --git a/HrynCo.DAL.EF/Core/BaseEfRepository.cs b/HrynCo.DAL.EF/Core/BaseEfRepository.cs new file mode 100644 index 0000000..8c04d3e --- /dev/null +++ b/HrynCo.DAL.EF/Core/BaseEfRepository.cs @@ -0,0 +1,218 @@ +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 : + IEfRepository + where TEntity : class, IEntity + where TDbContext : DbContext + where TEntityId : struct +{ + protected BaseEfRepository(TDbContext dbContext) + { + DbContext = dbContext; + DbSet = DbContext.Set(); + } + + public TDbContext DbContext { get; } + + private DbSet 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); + } + } + + public async Task 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 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) + { + Delete(id); + await DbContext.SaveChangesAsync(); + } + + public async Task DeleteAsync(IEnumerable id) + { + foreach (TEntityId entityId in id) + { + await DeleteAsync(entityId); + } + } + + public async Task DeleteAsync(TEntity entity) + { + DoRemove(entity); + await DbContext.SaveChangesAsync(); + } + + public virtual async Task 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 Get( + Expression>? filter = null, + Func, IOrderedQueryable>? orderBy = null, + string includeProperties = "") + { + IQueryable query = DbContext.Set(); + + 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 GetAll() + { + return DbContext.Set().AsQueryable(); + } + + public void RemoveRange(IEnumerable entities) + { + DbContext.Set().RemoveRange(entities); + } + + public void Remove(TEntity entity) + { + DbContext.Set().Remove(entity); + } + + public async Task> GetAllAsync() + { + return await DbContext.Set().ToListAsync(); + } + + public async Task Exists(TEntityId id) + { + return await DbContext.Set().AnyAsync(e => e.Id.Equals(id)); + } + + public virtual TEntity? GetById(TEntityId id) + { + return DbContext.Set().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); + } +} diff --git a/HrynCo.DAL.EF/Core/BaseRepository.cs b/HrynCo.DAL.EF/Core/BaseRepository.cs new file mode 100644 index 0000000..b6da603 --- /dev/null +++ b/HrynCo.DAL.EF/Core/BaseRepository.cs @@ -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 + where TEntity : class, IEntity + where TDbContext : DbContext + where TEfRepository : BaseEfRepository + where TEntityId : struct +{ + private readonly Lazy _lazyEfRepository; + + protected BaseRepository() + { + _lazyEfRepository = new Lazy(CreateEfRepository); + } + + protected TEfRepository EfRepository => _lazyEfRepository.Value; + + protected abstract TEfRepository CreateEfRepository(); +} diff --git a/HrynCo.DAL.EF/Core/EfRepository.cs b/HrynCo.DAL.EF/Core/EfRepository.cs deleted file mode 100644 index 8d4db72..0000000 --- a/HrynCo.DAL.EF/Core/EfRepository.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Linq.Expressions; -using Microsoft.EntityFrameworkCore; - -namespace HrynCo.DAL.EF.Core; - -public abstract class EfRepository - where TDbContext : DbContext - where TEntity : class -{ - protected TDbContext DbContext { get; } - protected DbSet DbSet { get; } - - protected EfRepository(TDbContext dbContext) - { - DbContext = dbContext; - DbSet = dbContext.Set(); - } - - protected async Task AddAsync(TEntity entity, CancellationToken ct = default) - { - await DbSet.AddAsync(entity, ct); - } - - protected async Task AddRangeAsync(IEnumerable 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 entities) - { - DbSet.RemoveRange(entities); - } - - protected Task ExistsAsync(Expression> predicate, CancellationToken ct = default) => - DbSet.AnyAsync(predicate, ct); -} diff --git a/HrynCo.DAL.EF/Core/EfTransactionAdapter.cs b/HrynCo.DAL.EF/Core/EfTransactionAdapter.cs index 6408ab6..a59be7d 100644 --- a/HrynCo.DAL.EF/Core/EfTransactionAdapter.cs +++ b/HrynCo.DAL.EF/Core/EfTransactionAdapter.cs @@ -1,29 +1,29 @@ +namespace HrynCo.DAL.EF.Core; + using HrynCo.DAL.Abstract; using Microsoft.EntityFrameworkCore.Storage; -namespace HrynCo.DAL.EF.Core; - -internal sealed class EfTransactionAdapter : ITransaction +public 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) { - return _transaction.CommitAsync(cancellationToken); + return _efTransaction.CommitAsync(cancellationToken); } public Task RollbackAsync(CancellationToken cancellationToken = default) { - return _transaction.RollbackAsync(cancellationToken); + return _efTransaction.RollbackAsync(cancellationToken); } public ValueTask DisposeAsync() { - return _transaction.DisposeAsync(); + return _efTransaction.DisposeAsync(); } } diff --git a/HrynCo.DAL.EF/Core/EfUnitOfWork.cs b/HrynCo.DAL.EF/Core/EfUnitOfWork.cs index b5413ab..3aa272c 100644 --- a/HrynCo.DAL.EF/Core/EfUnitOfWork.cs +++ b/HrynCo.DAL.EF/Core/EfUnitOfWork.cs @@ -1,10 +1,10 @@ +namespace HrynCo.DAL.EF.Core; + using HrynCo.DAL.Abstract; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; -namespace HrynCo.DAL.EF.Core; - -public abstract class EfUnitOfWork : IUnitOfWork +public class EfUnitOfWork : IUnitOfWork where TDbContext : DbContext { private readonly TDbContext _context; @@ -15,11 +15,6 @@ public abstract class EfUnitOfWork : IUnitOfWork _context = context; } - public Task SaveChangesAsync(CancellationToken cancellationToken = default) - { - return _context.SaveChangesAsync(cancellationToken); - } - public async Task BeginTransactionAsync(CancellationToken cancellationToken = default) { if (_currentTransaction != null) @@ -27,13 +22,8 @@ public abstract class EfUnitOfWork : IUnitOfWork return _currentTransaction; } - IDbContextTransaction tx = await _context.Database.BeginTransactionAsync(cancellationToken); - _currentTransaction = new EfTransactionAdapter(tx); - return _currentTransaction; - } - - public ITransaction? GetCurrentTransaction() - { + IDbContextTransaction transaction = await _context.Database.BeginTransactionAsync(cancellationToken); + _currentTransaction = new EfTransactionAdapter(transaction); return _currentTransaction; } @@ -41,6 +31,7 @@ public abstract class EfUnitOfWork : IUnitOfWork { ITransaction? existing = GetCurrentTransaction(); bool ownsTransaction = existing is null; + ITransaction tx = existing ?? await BeginTransactionAsync(); try @@ -73,6 +64,7 @@ public abstract class EfUnitOfWork : IUnitOfWork { ITransaction? existing = GetCurrentTransaction(); bool ownsTransaction = existing is null; + ITransaction tx = existing ?? await BeginTransactionAsync(); try @@ -102,4 +94,9 @@ public abstract class EfUnitOfWork : IUnitOfWork } } } + + public ITransaction? GetCurrentTransaction() + { + return _currentTransaction; + } } diff --git a/HrynCo.DAL.EF/Core/IEfRepository.cs b/HrynCo.DAL.EF/Core/IEfRepository.cs new file mode 100644 index 0000000..a20b304 --- /dev/null +++ b/HrynCo.DAL.EF/Core/IEfRepository.cs @@ -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 : IEfRepository + where TEntity : IEntity where TEntityId : struct +{ + TEntity Add(TEntity entity, bool save = true); + void Add(TEntity[] entities, bool save = true); + Task AddAsync(TEntity entity, bool save = true); + + void Delete(TEntity[] entities); + void Delete(TEntity entity); + void Delete(IEnumerable id); + void Delete(TEntityId id); + + Task DeleteAsync(TEntityId id); + Task DeleteAsync(IEnumerable id); + Task DeleteAsync(TEntity entity); + + IQueryable Get( + Expression>? filter = null, + Func, IOrderedQueryable>? orderBy = null, + string includeProperties = ""); + + IQueryable GetAll(); + + void RemoveRange(IEnumerable entities); + void Remove(TEntity entity); + + Task> GetAllAsync(); + + Task Exists(TEntityId id); + + TEntity? GetById(TEntityId id); + Task GetByIdAsync(TEntityId id); + Task UpdateAsync(TEntity entity, bool save = true); + + void Update(TEntity entity, bool save = true); + + Task SaveChangesAsync(); +} + +public interface IEfRepository : IEfRepository + where TEntity : IEntity +{ +} diff --git a/HrynCo.DAL.EF/Exceptions/UnexpectedEntityStateException.cs b/HrynCo.DAL.EF/Exceptions/UnexpectedEntityStateException.cs deleted file mode 100644 index 1b19c88..0000000 --- a/HrynCo.DAL.EF/Exceptions/UnexpectedEntityStateException.cs +++ /dev/null @@ -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}") - { - } -} diff --git a/README.md b/README.md new file mode 100644 index 0000000..e78becb --- /dev/null +++ b/README.md @@ -0,0 +1,207 @@ +# 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 `` in `.csproj` files**. + +At publish time, the TC `HrynCo / HrynCo.EF / publish` build: + +1. Writes the current build number into `Directory.Build.props` as `%build.number%`. +2. Builds and packs both projects. +3. Pushes the resulting `.nupkg` files to nuget.org. + +The build number follows the pattern `1.0.` (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 { + <> + +object Id + +DateTimeOffset Created + +DateTimeOffset? Updated + } + class IEntityTId { + <> + +TId Id + } + class EntityTId { + <> + +TId Id + +DateTimeOffset Created + +DateTimeOffset? Updated + } + class Entity { + <> + +Guid Id + } + class NamedEntity { + <> + +string Name + } + class IUnitOfWork { + <> + +BeginTransactionAsync() Task~ITransaction~ + +GetCurrentTransaction() ITransaction? + +ExecuteInTransactionAsync(action) Task + +ExecuteInTransactionAsync~TResult~(action) Task~TResult~ + } + class ITransaction { + <> + +CommitAsync() Task + +RollbackAsync() Task + +DisposeAsync() ValueTask + } + class PagedResultT { + <> + +IReadOnlyList~T~ Items + +int Page + +int PageSize + +int TotalCount + } + } + + namespace HrynCo_DAL_EF { + class IEfRepository { + <> + +ClearChangeTracker() + } + class IEfRepositoryTEntityTId { + <> + +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 { + <> + } + class BaseEfRepositoryTDbContextTEntityTEntityId { + <> + +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 { + <> + #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` | Base entity contracts | +| `Entity` / `Entity` | Base entity implementations with auto-generated `Id` | +| `NamedEntity` | Entity with a `Name` property | +| `IRepository` | Generic async repository interface | +| `IUnitOfWork` | Unit of work interface with transaction support | +| `ITransaction` | Async transaction contract | +| `PagedResult` | Pagination result wrapper | + +### `HrynCo.DAL.EF` + +Entity Framework Core implementations of the abstract contracts. + +| Type | Description | +|---|---| +| `BaseRepository` | Base repository with common CRUD operations | +| `BaseEfRepository` | EF Core repository with `DbContext` access | +| `IEfRepository` | 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 +{ + public ProductRepository(YourDbContext context) : base(context) { } +} + +// 3. Register in DI +services.AddScoped, ProductRepository>(); +services.AddScoped>(); +``` + +## Related + +- [IT-642](https://yt.grynco.com.ua/issue/IT-642) — Extract `PagedResult` builder as reusable `IQueryable` extension