diff --git a/src/Easify.Ef.Testing.UnitTests/DbContextExtensionsTests.cs b/src/Easify.Ef.Testing.UnitTests/DbContextExtensionsTests.cs index f44697f..b523d40 100644 --- a/src/Easify.Ef.Testing.UnitTests/DbContextExtensionsTests.cs +++ b/src/Easify.Ef.Testing.UnitTests/DbContextExtensionsTests.cs @@ -15,6 +15,8 @@ // along with this program. If not, see . // +using Easify.Ef.UnitOfWork; + namespace Easify.Ef.Testing.UnitTests; public class DbContextExtensionsTests diff --git a/src/Easify.Ef.Testing.UnitTests/Models/SampleDbContext.cs b/src/Easify.Ef.Testing.UnitTests/Models/SampleDbContext.cs index 610a429..72227f7 100644 --- a/src/Easify.Ef.Testing.UnitTests/Models/SampleDbContext.cs +++ b/src/Easify.Ef.Testing.UnitTests/Models/SampleDbContext.cs @@ -15,6 +15,8 @@ // along with this program. If not, see . // +using Easify.Http; + namespace Easify.Ef.Testing.UnitTests.Models; public class SampleDbContext : DbContextBase diff --git a/src/Easify.Ef.Testing.UnitTests/Usings.cs b/src/Easify.Ef.Testing.UnitTests/Usings.cs index d40c248..f12dfdb 100644 --- a/src/Easify.Ef.Testing.UnitTests/Usings.cs +++ b/src/Easify.Ef.Testing.UnitTests/Usings.cs @@ -1,10 +1,7 @@ global using Easify.Ef.Testing.UnitTests.Models; global using Easify.Ef.Testing.Extensions; -global using Easify.Http; global using Microsoft.EntityFrameworkCore; global using Microsoft.Extensions.DependencyInjection; - -global using EfCore.UnitOfWork; global using FluentAssertions; global using Xunit; diff --git a/src/Easify.Ef.Testing/Extensions/EntityListExtensions.cs b/src/Easify.Ef.Testing/Extensions/EntityListExtensions.cs index 048bfcc..eb33910 100644 --- a/src/Easify.Ef.Testing/Extensions/EntityListExtensions.cs +++ b/src/Easify.Ef.Testing/Extensions/EntityListExtensions.cs @@ -15,6 +15,8 @@ // along with this program. If not, see . // +using Easify.Ef.UnitOfWork; + namespace Easify.Ef.Testing.Extensions; public static class EntityListExtensions diff --git a/src/Easify.Ef.Testing/Usings.cs b/src/Easify.Ef.Testing/Usings.cs index 3dfddac..115040c 100644 --- a/src/Easify.Ef.Testing/Usings.cs +++ b/src/Easify.Ef.Testing/Usings.cs @@ -7,5 +7,5 @@ global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.DependencyInjection.Extensions; -global using EfCore.UnitOfWork; +global using Easify.Ef.UnitOfWork; global using AutoMapper; diff --git a/src/Easify.Ef.UnitTests/UnitOfWork/Entities/City.cs b/src/Easify.Ef.UnitTests/UnitOfWork/Entities/City.cs new file mode 100644 index 0000000..0c55b47 --- /dev/null +++ b/src/Easify.Ef.UnitTests/UnitOfWork/Entities/City.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace Easify.Ef.UnitOfWork.UnitTests.Entities +{ + public class City + { + public int Id { get; set; } + public string Name { get; set; } + public int CountryId { get; set; } + public Country Country { get; set; } + public List Towns { get; set; } + } +} \ No newline at end of file diff --git a/src/Easify.Ef.UnitTests/UnitOfWork/Entities/Country.cs b/src/Easify.Ef.UnitTests/UnitOfWork/Entities/Country.cs new file mode 100644 index 0000000..d2a25df --- /dev/null +++ b/src/Easify.Ef.UnitTests/UnitOfWork/Entities/Country.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Easify.Ef.UnitOfWork.UnitTests.Entities +{ + public class Country + { + [Key] + public int Id { get; set; } + public string Name { get; set; } + public List Cities { get; set; } + } +} diff --git a/src/Easify.Ef.UnitTests/UnitOfWork/Entities/Customer.cs b/src/Easify.Ef.UnitTests/UnitOfWork/Entities/Customer.cs new file mode 100644 index 0000000..e438eae --- /dev/null +++ b/src/Easify.Ef.UnitTests/UnitOfWork/Entities/Customer.cs @@ -0,0 +1,9 @@ +namespace Easify.Ef.UnitOfWork.UnitTests.Entities +{ + public class Customer + { + public int Id { get; set; } + public string Name { get; set; } + public int Age { get; set; } + } +} diff --git a/src/Easify.Ef.UnitTests/UnitOfWork/Entities/Town.cs b/src/Easify.Ef.UnitTests/UnitOfWork/Entities/Town.cs new file mode 100644 index 0000000..23cee39 --- /dev/null +++ b/src/Easify.Ef.UnitTests/UnitOfWork/Entities/Town.cs @@ -0,0 +1,10 @@ +namespace Easify.Ef.UnitOfWork.UnitTests.Entities +{ + public class Town + { + public int Id { get; set; } + public string Name { get; set; } + public int CityId { get; set; } + public City City { get; set; } + } +} diff --git a/src/Easify.Ef.UnitTests/UnitOfWork/InMemoryDbContext.cs b/src/Easify.Ef.UnitTests/UnitOfWork/InMemoryDbContext.cs new file mode 100644 index 0000000..0017759 --- /dev/null +++ b/src/Easify.Ef.UnitTests/UnitOfWork/InMemoryDbContext.cs @@ -0,0 +1,27 @@ +using System; +using Easify.Ef.UnitOfWork.UnitTests.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection; + +namespace Easify.Ef.UnitOfWork.UnitTests +{ + public class InMemoryDbContext : DbContext + { + public DbSet Countries { get; set; } + public DbSet Customers { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + var name = $"{nameof(InMemoryDbContext)}_{Guid.NewGuid()}"; + + var serviceProvider = new ServiceCollection() + .AddEntityFrameworkInMemoryDatabase() + .BuildServiceProvider(); + + optionsBuilder.UseInMemoryDatabase(name) + .ConfigureWarnings(warnings => warnings.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .UseInternalServiceProvider(serviceProvider); + } + } +} \ No newline at end of file diff --git a/src/Easify.Ef.UnitTests/UnitOfWork/RepositoryFixture.cs b/src/Easify.Ef.UnitTests/UnitOfWork/RepositoryFixture.cs new file mode 100644 index 0000000..d9d9fa1 --- /dev/null +++ b/src/Easify.Ef.UnitTests/UnitOfWork/RepositoryFixture.cs @@ -0,0 +1,50 @@ +using Easify.Ef.UnitOfWork.UnitTests.Entities; + +namespace Easify.Ef.UnitOfWork.UnitTests +{ + public class RepositoryFixture + { + private static IEnumerable TestCountries => new List + { + new Country {Id = 1, Name = "A"}, + new Country {Id = 2, Name = "B"} + }; + + private static IEnumerable TestCities => new List + { + new City { Id = 1, Name = "A", CountryId = 1}, + new City { Id = 2, Name = "B", CountryId = 2}, + new City { Id = 3, Name = "C", CountryId = 1}, + new City { Id = 4, Name = "D", CountryId = 2}, + new City { Id = 5, Name = "E", CountryId = 1}, + new City { Id = 6, Name = "F", CountryId = 2}, + }; + + private static IEnumerable TestTowns => new List + { + new Town { Id = 1, Name="A", CityId = 1 }, + new Town { Id = 2, Name="B", CityId = 2 }, + new Town { Id = 3, Name="C", CityId = 3 }, + new Town { Id = 4, Name="D", CityId = 4 }, + new Town { Id = 5, Name="E", CityId = 5 }, + new Town { Id = 6, Name="F", CityId = 6 }, + }; + + public InMemoryDbContext DbContext() + { + var dbContext = new InMemoryDbContext(); + + dbContext.AddRange(TestCountries); + dbContext.AddRange(TestCities); + dbContext.AddRange(TestTowns); + + dbContext.SaveChanges(); + + return dbContext; + } + + public IUnitOfWork CreateUnitOfWork() => new UnitOfWork(DbContext()); + + public IRepository CreateRepository() where T : class => new Repository(DbContext()); + } +} \ No newline at end of file diff --git a/src/Easify.Ef.UnitTests/UnitOfWork/RepositoryTests.cs b/src/Easify.Ef.UnitTests/UnitOfWork/RepositoryTests.cs new file mode 100644 index 0000000..25c0ffd --- /dev/null +++ b/src/Easify.Ef.UnitTests/UnitOfWork/RepositoryTests.cs @@ -0,0 +1,171 @@ +using System.Threading.Tasks; +using Easify.Ef.UnitOfWork.UnitTests.Entities; +using Easify.Ef.UnitOfWork.Extensions; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Easify.Ef.UnitOfWork.UnitTests +{ + public class RepositoryTests : IClassFixture + { + private readonly RepositoryFixture _fixture; + + public RepositoryTests(RepositoryFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public void Should_GetList_ResolveTheRightSetOfDataFromContext() + { + // Arrange + var repository = _fixture.CreateRepository(); + + // Act + var actual = repository.GetList(t => t.Name == "C", q => q.Include(t => t.Country).PagedBy(0, 1)); + + // Assert + + actual.Should().HaveCount(1) + .And.Contain(m => m.Country != null && m.CountryId != 0 && m.Country.Name == "A" && m.Country.Id == 1); + } + + [Fact] + public async Task Should_GetListAsync_ResolveTheRightSetOfDataFromContext() + { + // Arrange + var repository = _fixture.CreateRepository(); + + // Act + var actual = + await repository.GetListAsync(t => t.Name == "C", q => q.Include(t => t.Country).PagedBy(0, 1)); + + // Assert + + actual.Should().HaveCount(1) + .And.Contain(m => m.Country != null && m.CountryId != 0 && m.Country.Name == "A" && m.Country.Id == 1); + } + + [Fact] + public void Should_GetProjectedList_WithProjection_ResolveTheSmallerSetOfDataFromContext() + { + // Arrange + var repository = _fixture.CreateRepository(); + + // Act + var actual = repository.GetProjectedList(t => t.Name == "C", m => m.Name, q => q.Include(t => t.Country)); + + // Assert + actual.Should().HaveCount(1) + .And.Contain(m => m == "C"); + } + + [Fact] + public async Task Should_GetProjectedListAsync_WithProjection_ResolveTheSmallerSetOfDataFromContext() + { + // Arrange + var repository = _fixture.CreateRepository(); + + // Act + var actual = + await repository.GetProjectedListAsync(t => t.Name == "C", m => m.Name); + // await repository.GetProjectedListAsync(t => t.Name == "C", m => m.Name, q => q.Include(t => t.Country)); + + // Assert + actual.Should().HaveCount(1) + .And.Contain(m => m == "C"); + } + + [Fact] + public void Should_GetFirstOrDefault_ResolveTheRightSetOfDataFromContext() + { + // Arrange + var repository = _fixture.CreateRepository(); + + // Act + var actual = repository.GetFirstOrDefault(t => t.Name == "C", q => q.Include(t => t.Country)); + + // Assert + actual.Should().NotBeNull(); + actual.Name.Should().Be("C"); + actual.CountryId.Should().Be(1); + actual.Country.Should().BeEquivalentTo(new Country {Name = "A", Id = 1}, m => m.Excluding(t => t.Cities)); + } + + [Fact] + public async Task Should_GetFirstOrDefaultAsync_ResolveTheRightSetOfDataFromContext() + { + // Arrange + var repository = _fixture.CreateRepository(); + + // Act + var actual = await repository.GetFirstOrDefaultAsync(t => t.Name == "C", q => q.Include(t => t.Country)); + + // Assert + actual.Should().NotBeNull(); + actual.Name.Should().Be("C"); + actual.CountryId.Should().Be(1); + actual.Country.Should().BeEquivalentTo(new Country {Name = "A", Id = 1}, m => m.Excluding(t => t.Cities)); + } + + [Fact] + public async Task Should_GetFirstOrDefaultAsync_ReturnsMultipleLevelOfHierarchyWhenItsIncludedInQuery() + { + // Arrange + var repository = _fixture.CreateRepository(); + + // Act + var actual = await repository.GetFirstOrDefaultAsync(t => t.Name == "A", + country => country.Include(c => c.Cities).ThenInclude(city => city.Towns)); + + // Assert + actual.Should().NotBeNull(); + actual.Cities.Should().HaveCount(3).And.Contain(m => m.Towns.Count == 1); + } + + [Fact] + public void Should_Find_ReturnsCertainEntityByIdentifier() + { + // Arrange + var repository = _fixture.CreateRepository(); + + // Act + var actual = repository.Find(1); + + // Assert + actual.Should().NotBeNull(); + actual.Name.Should().Be("A"); + } + + [Fact] + public async Task Should_FindAsync_ReturnsCertainEntityByIdentifier() + { + // Arrange + var repository = _fixture.CreateRepository(); + + // Act + var actual = await repository.FindAsync(1); + + // Assert + actual.Should().NotBeNull(); + actual.Name.Should().Be("A"); + } + + [Theory] + [InlineData("A", 1)] + [InlineData("B", 1)] + [InlineData("C", 0)] + public void Should_Count_ReturnsTheNumberOfRecordsAccordingly(string phrase, int expectedCount) + { + // Arrange + var repository = _fixture.CreateRepository(); + + // Act + var actual = repository.Count(m => m.Name == phrase); + + // Assert + actual.Should().Be(expectedCount); + } + } +} \ No newline at end of file diff --git a/src/Easify.Ef.UnitTests/UnitOfWork/UnitOfWorkTests.cs b/src/Easify.Ef.UnitTests/UnitOfWork/UnitOfWorkTests.cs new file mode 100644 index 0000000..ac447c6 --- /dev/null +++ b/src/Easify.Ef.UnitTests/UnitOfWork/UnitOfWorkTests.cs @@ -0,0 +1,84 @@ +using Easify.Ef.UnitOfWork.UnitTests.Entities; + +namespace Easify.Ef.UnitOfWork.UnitTests +{ + public class UnitOfWorkTests : IClassFixture + { + private readonly RepositoryFixture _fixture; + + public UnitOfWorkTests(RepositoryFixture fixture) => _fixture = fixture; + + [Fact] + public void Should_SaveChanges_StoreTheDataCorrectly() + { + // Arrange + var uow = _fixture.CreateUnitOfWork(); + + // Act + var repository = uow.GetRepository(); + repository.Insert(new Country() {Id = 100, Name = "Country 100"}); + repository.Insert(new Country() {Id = 101, Name = "Country 101"}); + repository.Insert(new Country() {Id = 102, Name = "Country 102"}); + uow.SaveChanges(); + + var actual = repository.GetList(); + + // Assert + + actual.Should().HaveCount(5); + } + + [Fact] + public async Task Should_SaveChangesAsync_StoreTheDataCorrectly() + { + // Arrange + var uow = _fixture.CreateUnitOfWork(); + + // Act + var repository = uow.GetRepository(); + repository.Insert(new Country() {Id = 100, Name = "Country 100"}); + repository.Insert(new Country() {Id = 101, Name = "Country 101"}); + repository.Insert(new Country() {Id = 102, Name = "Country 102"}); + await uow.SaveChangesAsync(); + + var actual = await repository.GetListAsync(); + + // Assert + actual.Should().HaveCount(5); + } + + [Fact] + public void Should_SaveChanges_WhenMultipleOperation_ThenChangeTheSateOfTheDataSourceAccordingly() + { + // Arrange + var uow = _fixture.CreateUnitOfWork(); + var repository = uow.GetRepository(); + + // Act + var list = repository.GetList().ToList(); + var entityForUpdate = list.First(); + entityForUpdate.Name = $"Country {entityForUpdate.Id}"; + + repository.Insert(new List + { + new Country {Id = 100, Name = "Country 100"}, + new Country {Id = 101, Name = "Country 101"}, + new Country {Id = 102, Name = "Country 102"} + }); + + repository.Update(entityForUpdate); + repository.Delete(list.Last().Id); + + uow.SaveChanges(); + + var actual = repository.GetList(); + + // Assert + actual.Should().HaveCount(4) + .And.Contain(m => m.Id == 1 && m.Name == "Country 1") + .And.Contain(m => m.Id == 100 && m.Name == "Country 100") + .And.Contain(m => m.Id == 101 && m.Name == "Country 101") + .And.Contain(m => m.Id == 102 && m.Name == "Country 102"); + } + } +} \ No newline at end of file diff --git a/src/Easify.Ef.UnitTests/Usings.cs b/src/Easify.Ef.UnitTests/Usings.cs index 09454b6..e02b3d5 100644 --- a/src/Easify.Ef.UnitTests/Usings.cs +++ b/src/Easify.Ef.UnitTests/Usings.cs @@ -7,7 +7,7 @@ global using Microsoft.EntityFrameworkCore; -global using EfCore.UnitOfWork; +global using Easify.Ef.UnitOfWork; global using FluentAssertions; global using NSubstitute; global using Xunit; diff --git a/src/Easify.Ef.sln b/src/Easify.Ef.sln index 509645b..dde4f06 100644 --- a/src/Easify.Ef.sln +++ b/src/Easify.Ef.sln @@ -1,15 +1,15 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.26730.16 +# Visual Studio Version 17 +VisualStudioVersion = 17.4.33122.133 MinimumVisualStudioVersion = 15.0.26124.0 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Easify.Ef", "Easify.Ef\Easify.Ef.csproj", "{172C7EEB-9663-4B4C-94A9-295F4961C285}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Easify.Ef.UnitTests", "Easify.Ef.UnitTests\Easify.Ef.UnitTests.csproj", "{EE8ACCA4-BFEB-4A17-80ED-86C7CF3BC781}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Easify.Ef.UnitTests", "Easify.Ef.UnitTests\Easify.Ef.UnitTests.csproj", "{EE8ACCA4-BFEB-4A17-80ED-86C7CF3BC781}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Easify.Ef.Testing", "Easify.Ef.Testing\Easify.Ef.Testing.csproj", "{8258D7DC-CE00-46F6-B7E2-C54FA9259AE5}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Easify.Ef.Testing", "Easify.Ef.Testing\Easify.Ef.Testing.csproj", "{8258D7DC-CE00-46F6-B7E2-C54FA9259AE5}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Easify.Ef.Testing.UnitTests", "Easify.Ef.Testing.UnitTests\Easify.Ef.Testing.UnitTests.csproj", "{D4B8183D-0B90-470C-BE92-89EC3B99C246}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Easify.Ef.Testing.UnitTests", "Easify.Ef.Testing.UnitTests\Easify.Ef.Testing.UnitTests.csproj", "{D4B8183D-0B90-470C-BE92-89EC3B99C246}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/src/Easify.Ef/Easify.Ef.csproj b/src/Easify.Ef/Easify.Ef.csproj index 79026ee..90329bd 100644 --- a/src/Easify.Ef/Easify.Ef.csproj +++ b/src/Easify.Ef/Easify.Ef.csproj @@ -13,8 +13,10 @@ - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Easify.Ef/UnitOfWork/Extensions/PagingOptions.cs b/src/Easify.Ef/UnitOfWork/Extensions/PagingOptions.cs new file mode 100644 index 0000000..26ca7b6 --- /dev/null +++ b/src/Easify.Ef/UnitOfWork/Extensions/PagingOptions.cs @@ -0,0 +1,25 @@ +using System; + +namespace Easify.Ef.UnitOfWork.Extensions +{ + public sealed class PagingOptions + { + private const int DefaultPageSize = 20; + + public PagingOptions(int pageIndex, int pageSize = DefaultPageSize) + { + if (pageSize <= 0) throw new ArgumentOutOfRangeException(nameof(pageSize)); + if (pageIndex <= 0) throw new ArgumentOutOfRangeException(nameof(pageIndex)); + + PageIndex = pageIndex; + PageSize = pageSize; + } + + public PagingOptions() : this(0, DefaultPageSize) + { + } + + public int PageIndex { get; } + public int PageSize { get; } + } +} \ No newline at end of file diff --git a/src/Easify.Ef/UnitOfWork/Extensions/QueryableExtensions.cs b/src/Easify.Ef/UnitOfWork/Extensions/QueryableExtensions.cs new file mode 100644 index 0000000..458212b --- /dev/null +++ b/src/Easify.Ef/UnitOfWork/Extensions/QueryableExtensions.cs @@ -0,0 +1,33 @@ +using System; +using System.Linq; + +namespace Easify.Ef.UnitOfWork.Extensions +{ + public static class QueryableExtensions + { + public static IQueryable PagedBy(this IQueryable queryable, PagingOptions options) + { + if (queryable == null) throw new ArgumentNullException(nameof(queryable)); + if (options == null) throw new ArgumentNullException(nameof(options)); + + return queryable.PagedBy(options.PageIndex, options.PageSize); + } + + public static IQueryable PagedBy(this IQueryable queryable, int pageIndex, int pageSize) + { + if (queryable == null) throw new ArgumentNullException(nameof(queryable)); + if (pageIndex < 0) throw new ArgumentOutOfRangeException(nameof(pageIndex)); + if (pageSize <= 0) throw new ArgumentOutOfRangeException(nameof(pageSize)); + + return queryable.Skip(pageIndex * pageSize).Take(pageSize); + } + + public static IQueryable ProjectTo(this IQueryable queryable, Func projection) + { + if (queryable == null) throw new ArgumentNullException(nameof(queryable)); + if (projection == null) throw new ArgumentNullException(nameof(projection)); + + return queryable.Select(m => projection(m)); + } + } +} \ No newline at end of file diff --git a/src/Easify.Ef/UnitOfWork/IRepository.cs b/src/Easify.Ef/UnitOfWork/IRepository.cs new file mode 100644 index 0000000..deea63c --- /dev/null +++ b/src/Easify.Ef/UnitOfWork/IRepository.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore.ChangeTracking; + +namespace Easify.Ef.UnitOfWork +{ + public interface IRepository where TEntity : class + { + IQueryable FromSql(string sql, params object[] parameters); + TEntity Find(params object[] keyValues); + ValueTask FindAsync(params object[] keyValues); + ValueTask FindAsync(object[] keyValues, CancellationToken cancellationToken); + int Count(Expression> predicate = null); + void Insert(TEntity entity); + void Insert(params TEntity[] entities); + void Insert(IEnumerable entities); + ValueTask> InsertAsync(TEntity entity, CancellationToken cancellationToken = default); + Task InsertAsync(params TEntity[] entities); + Task InsertAsync(IEnumerable entities, CancellationToken cancellationToken = default); + void Update(TEntity entity); + void Update(params TEntity[] entities); + void Update(IEnumerable entities); + void Delete(object id); + void Delete(TEntity entity); + void Delete(params TEntity[] entities); + void Delete(IEnumerable entities); + + Task> GetListAsync(Expression> predicate, + Func, IQueryable> extendBy = null); + + Task> GetListAsync(Func, IQueryable> extendBy = null); + + IEnumerable GetList(Expression> predicate, + Func, IQueryable> extendBy = null); + + IEnumerable GetList(Func, IQueryable> extendBy = null); + + Task GetFirstOrDefaultAsync(Expression> predicate, + Func, IQueryable> extendBy = null); + + TEntity GetFirstOrDefault(Expression> predicate, + Func, IQueryable> extendBy = null); + + Task> GetProjectedListAsync(Expression> predicate, + Func projectedBy, + Func, IQueryable> extendBy = null); + + Task> GetProjectedListAsync( + Func projectedBy, + Func, IQueryable> extendBy = null); + + IEnumerable GetProjectedList(Expression> predicate, + Func projectedBy, + Func, IQueryable> extendBy = null); + + IEnumerable GetProjectedList(Func projectedBy, + Func, IQueryable> extendBy = null); + } +} \ No newline at end of file diff --git a/src/Easify.Ef/UnitOfWork/IRepositoryFactory.cs b/src/Easify.Ef/UnitOfWork/IRepositoryFactory.cs new file mode 100644 index 0000000..5efb8fa --- /dev/null +++ b/src/Easify.Ef/UnitOfWork/IRepositoryFactory.cs @@ -0,0 +1,7 @@ +namespace Easify.Ef.UnitOfWork +{ + public interface IRepositoryFactory + { + IRepository GetRepository(bool hasCustomRepository = false) where TEntity : class; + } +} \ No newline at end of file diff --git a/src/Easify.Ef/UnitOfWork/IUnitOfWork.cs b/src/Easify.Ef/UnitOfWork/IUnitOfWork.cs new file mode 100644 index 0000000..2d85a67 --- /dev/null +++ b/src/Easify.Ef/UnitOfWork/IUnitOfWork.cs @@ -0,0 +1,17 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore.ChangeTracking; + +namespace Easify.Ef.UnitOfWork +{ + public interface IUnitOfWork : IDisposable + { + IRepository GetRepository(bool hasCustomRepository = false) where TEntity : class; + int SaveChanges(bool ensureAutoHistory = false); + Task SaveChangesAsync(bool ensureAutoHistory = false); + int ExecuteSqlCommand(string sql, params object[] parameters); + IQueryable FromSql(string sql, params object[] parameters) where TEntity : class; + void TrackGraph(object rootEntity, Action callback); + } +} \ No newline at end of file diff --git a/src/Easify.Ef/UnitOfWork/IUnitOfWorkOfT.cs b/src/Easify.Ef/UnitOfWork/IUnitOfWorkOfT.cs new file mode 100644 index 0000000..f5809c8 --- /dev/null +++ b/src/Easify.Ef/UnitOfWork/IUnitOfWorkOfT.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; + +namespace Easify.Ef.UnitOfWork +{ + public interface IUnitOfWork : IUnitOfWork where TContext : DbContext { + TContext DbContext { get; } + Task SaveChangesAsync(bool ensureAutoHistory = false, params IUnitOfWork[] unitOfWorks); + } +} diff --git a/src/Easify.Ef/UnitOfWork/Repository.cs b/src/Easify.Ef/UnitOfWork/Repository.cs new file mode 100644 index 0000000..30038db --- /dev/null +++ b/src/Easify.Ef/UnitOfWork/Repository.cs @@ -0,0 +1,136 @@ +// Copyright (c) Arch team. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; + +namespace Easify.Ef.UnitOfWork +{ + public sealed class Repository : IRepository where TEntity : class + { + private readonly DbContext _dbContext; + private readonly DbSet _dbSet; + + public Repository(DbContext dbContext) + { + _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); + _dbSet = _dbContext.Set(); + } + + public async Task> GetListAsync(Expression> predicate, + Func, IQueryable> extendBy = null) => + await GetListInternal(predicate, extendBy).ToListAsync(); + + public async Task> GetProjectedListAsync(Expression> predicate, + Func projectedBy, + Func, IQueryable> extendBy = null) + { + var results = await GetListInternal(predicate, extendBy).ToListAsync(); + return results.Select(projectedBy).ToList(); + } + + public async Task> GetListAsync(Func, IQueryable> extendBy = null) => + await GetListInternal(p => true, extendBy).ToListAsync(); + + public async Task> GetProjectedListAsync( + Func projectedBy, + Func, IQueryable> extendBy = null) + { + var results = await GetListInternal(p => true, extendBy).ToListAsync(); + return results.Select(projectedBy).ToList(); + } + + public IEnumerable GetList(Expression> predicate, + Func, IQueryable> extendBy = null) => + GetListInternal(predicate, extendBy).ToList(); + + public IEnumerable GetProjectedList(Expression> predicate, + Func projectedBy, + Func, IQueryable> extendBy = null) => + GetListInternal(predicate, extendBy).AsEnumerable().Select(projectedBy).ToList(); + + public IEnumerable GetList(Func, IQueryable> extendBy = null) => + GetListInternal(p => true, extendBy).ToList(); + + public IEnumerable GetProjectedList(Func projectedBy, + Func, IQueryable> extendBy = null) => + GetListInternal(p => true, extendBy).AsEnumerable().Select(projectedBy).ToList(); + + public async Task GetFirstOrDefaultAsync(Expression> predicate, + Func, IQueryable> extendBy = null) => + await GetListInternal(predicate, extendBy).FirstOrDefaultAsync(); + + public TEntity GetFirstOrDefault(Expression> predicate, + Func, IQueryable> extendBy = null) => + GetListInternal(predicate, extendBy).FirstOrDefault(); + + private IQueryable GetListInternal(Expression> predicate, + Func, IQueryable> extend = null) + { + if (predicate == null) throw new ArgumentNullException(nameof(predicate)); + + var queryable = _dbSet.Where(predicate); + if (extend != null) + queryable = extend(queryable); + + return queryable; + } + private IQueryable GetListProjectedByInternal(Expression> predicate, + Func projectedBy, + Func, IQueryable> extend = null) + { + if (predicate == null) throw new ArgumentNullException(nameof(predicate)); + + var queryable = GetListInternal(predicate, extend); + return queryable.Select(i => projectedBy(i)); + } + + public IQueryable FromSql(string sql, params object[] parameters) => _dbSet.FromSqlRaw(sql, parameters); + + public TEntity Find(params object[] keyValues) => _dbSet.Find(keyValues); + + public ValueTask FindAsync(params object[] keyValues) => _dbSet.FindAsync(keyValues); + + public ValueTask FindAsync(object[] keyValues, CancellationToken cancellationToken) => _dbSet.FindAsync(keyValues, cancellationToken); + + public int Count(Expression> predicate = null) => predicate == null ? _dbSet.Count() : _dbSet.Count(predicate); + + public void Insert(TEntity entity) => _dbSet.Add(entity); + + public void Insert(params TEntity[] entities) => _dbSet.AddRange(entities); + + public void Insert(IEnumerable entities) => _dbSet.AddRange(entities); + + public ValueTask> InsertAsync(TEntity entity, CancellationToken cancellationToken = default) => _dbSet.AddAsync(entity, cancellationToken); + + public Task InsertAsync(params TEntity[] entities) => _dbSet.AddRangeAsync(entities); + + public Task InsertAsync(IEnumerable entities, CancellationToken cancellationToken = default) => _dbSet.AddRangeAsync(entities, cancellationToken); + + public void Update(TEntity entity) => _dbSet.Update(entity); + + public void Update(params TEntity[] entities) => _dbSet.UpdateRange(entities); + + public void Update(IEnumerable entities) => _dbSet.UpdateRange(entities); + + public void Delete(TEntity entity) => _dbSet.Remove(entity); + + public void Delete(object id) + { + var entity = _dbSet.Find(id); + if (entity != null) + { + Delete(entity); + } + } + + public void Delete(params TEntity[] entities) => _dbSet.RemoveRange(entities); + + public void Delete(IEnumerable entities) => _dbSet.RemoveRange(entities); + } +} diff --git a/src/Easify.Ef/UnitOfWork/UnitOfWork.cs b/src/Easify.Ef/UnitOfWork/UnitOfWork.cs new file mode 100644 index 0000000..7b81823 --- /dev/null +++ b/src/Easify.Ef/UnitOfWork/UnitOfWork.cs @@ -0,0 +1,102 @@ +// Copyright (c) Arch team. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Transactions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Easify.Ef.UnitOfWork +{ + public sealed class UnitOfWork : IRepositoryFactory, IUnitOfWork where TContext : DbContext + { + private bool _disposed; + private Dictionary _repositories; + + public UnitOfWork(TContext context) => DbContext = context ?? throw new ArgumentNullException(nameof(context)); + + public TContext DbContext { get; } + + public IRepository GetRepository(bool hasCustomRepository = false) where TEntity : class + { + _repositories ??= new Dictionary(); + + // TODO: Find a better way of resolving the repository + if (hasCustomRepository) + { + var repository = DbContext.GetService>(); + if (repository != null) + return repository; + } + + // TODO: Needs to be thread-safe + var type = typeof(TEntity); + if (!_repositories.ContainsKey(type)) + _repositories[type] = new Repository(DbContext); + + return (IRepository)_repositories[type]; + } + + public int ExecuteSqlCommand(string sql, params object[] parameters) => DbContext.Database.ExecuteSqlRaw(sql, parameters); + + public IQueryable FromSql(string sql, params object[] parameters) where TEntity : class => DbContext.Set().FromSqlRaw(sql, parameters); + + public int SaveChanges(bool ensureAutoHistory = false) + { + if (ensureAutoHistory) + DbContext.EnsureAutoHistory(); + + return DbContext.SaveChanges(); + } + + public async Task SaveChangesAsync(bool ensureAutoHistory = false) + { + if (ensureAutoHistory) + DbContext.EnsureAutoHistory(); + + return await DbContext.SaveChangesAsync(); + } + + public async Task SaveChangesAsync(bool ensureAutoHistory = false, params IUnitOfWork[] unitOfWorks) + { + using (var ts = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + var tasks = unitOfWorks.Select(async unitOfWork => await unitOfWork.SaveChangesAsync(ensureAutoHistory)).ToList(); + var results = await Task.WhenAll(tasks); + + var count = results.Sum(); + count += await SaveChangesAsync(ensureAutoHistory); + + ts.Complete(); + + return count; + } + } + + public void Dispose() + { + Dispose(true); + + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _repositories?.Clear(); + DbContext.Dispose(); + } + } + + _disposed = true; + } + + public void TrackGraph(object rootEntity, Action callback) => DbContext.ChangeTracker.TrackGraph(rootEntity, callback); + } +} diff --git a/src/Easify.Ef/UnitOfWork/UnitOfWorkServiceCollectionExtensions.cs b/src/Easify.Ef/UnitOfWork/UnitOfWorkServiceCollectionExtensions.cs new file mode 100644 index 0000000..8eff559 --- /dev/null +++ b/src/Easify.Ef/UnitOfWork/UnitOfWorkServiceCollectionExtensions.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Easify.Ef.UnitOfWork +{ + public static class UnitOfWorkServiceCollectionExtensions + { + public static IServiceCollection AddUnitOfWork(this IServiceCollection services) where TContext : DbContext + { + services.AddScoped>(); + services.AddScoped>(); + services.AddScoped, UnitOfWork>(); + + return services; + } + + public static IServiceCollection AddUnitOfWork(this IServiceCollection services) + where TContext1 : DbContext + where TContext2 : DbContext + { + services.AddScoped, UnitOfWork>(); + services.AddScoped, UnitOfWork>(); + + return services; + } + + public static IServiceCollection AddUnitOfWork(this IServiceCollection services) + where TContext1 : DbContext + where TContext2 : DbContext + where TContext3 : DbContext + { + services.AddScoped, UnitOfWork>(); + services.AddScoped, UnitOfWork>(); + services.AddScoped, UnitOfWork>(); + + return services; + } + + public static IServiceCollection AddUnitOfWork(this IServiceCollection services) + where TContext1 : DbContext + where TContext2 : DbContext + where TContext3 : DbContext + where TContext4 : DbContext + { + services.AddScoped, UnitOfWork>(); + services.AddScoped, UnitOfWork>(); + services.AddScoped, UnitOfWork>(); + services.AddScoped, UnitOfWork>(); + + return services; + } + + public static IServiceCollection AddCustomRepository(this IServiceCollection services) + where TEntity : class + where TRepository : class, IRepository + { + services.AddScoped, TRepository>(); + + return services; + } + } +} diff --git a/src/Easify.Ef/Usings.cs b/src/Easify.Ef/Usings.cs index 8cd5aee..a6ea6f1 100644 --- a/src/Easify.Ef/Usings.cs +++ b/src/Easify.Ef/Usings.cs @@ -8,4 +8,4 @@ global using Microsoft.EntityFrameworkCore.Infrastructure; global using Microsoft.Extensions.DependencyInjection; -global using EfCore.UnitOfWork; +global using Easify.Ef.UnitOfWork;