From 13a13a2243e0687df25e068a2bd5e000d2fa6cea Mon Sep 17 00:00:00 2001 From: Oleksandr Malomed Date: Wed, 16 Nov 2022 20:29:19 +0200 Subject: [PATCH 1/3] Update subentities meta when root entity is updated --- .../EntityRepositoryTests.cs | 79 +++++++++++++++++++ .../Mocks/TestContext.cs | 25 +++++- .../Mocks/TestEntity.cs | 16 ++++ .../Mocks/TestEntityRepository.cs | 2 +- .../Mocks/TestSubEntityRepository.cs | 15 ++++ ...onstarlab.EntityFramework.Extension.csproj | 1 + .../Repositories/BaseEntityRepository.cs | 64 ++++++++++++--- .../Repositories/EntityRepository.cs | 3 + .../Repositories/IEntityRepository.cs | 6 ++ .../Utils/TypeExtensions.cs | 58 ++++++++++++++ 10 files changed, 258 insertions(+), 11 deletions(-) create mode 100644 Monstarlab.EntityFramework.Extension.Tests/Mocks/TestSubEntityRepository.cs create mode 100644 Monstarlab.EntityFramework.Extension/Utils/TypeExtensions.cs diff --git a/Monstarlab.EntityFramework.Extension.Tests/EntityRepositoryTests.cs b/Monstarlab.EntityFramework.Extension.Tests/EntityRepositoryTests.cs index cc125c6..7f600fb 100644 --- a/Monstarlab.EntityFramework.Extension.Tests/EntityRepositoryTests.cs +++ b/Monstarlab.EntityFramework.Extension.Tests/EntityRepositoryTests.cs @@ -42,6 +42,85 @@ public void Setup() _repository = new TestEntityRepository(_context); } + [Test] + public async Task SubEntities1() + { + var entity = new TestEntity + { + Property = "whatever", + TestSubEntities = new List + { + new TestSubEntity + { + Property = "sub 1" + }, + new TestSubEntity + { + Property = "sub 2" + } + }, + TestSubEntity = new SingleTestSubEntity + { + Property = "sub 999" + } + }; + + var updatedEntity = await _repository.AddAsync(entity); + await _unitOfWork.CommitAsync(); + + Assert.AreNotEqual(default(Guid), updatedEntity.TestSubEntities.ElementAt(0).Id); + Assert.AreNotEqual(default(DateTime), updatedEntity.TestSubEntities.ElementAt(0).Created); + Assert.AreNotEqual(default(DateTime), updatedEntity.TestSubEntities.ElementAt(0).Updated); + Assert.AreEqual(updatedEntity.TestSubEntities.ElementAt(0).Created, updatedEntity.TestSubEntities.ElementAt(0).Updated); + Assert.AreNotEqual(default(DateTime), updatedEntity.TestSubEntities.ElementAt(1).Created); + Assert.AreNotEqual(default(DateTime), updatedEntity.TestSubEntities.ElementAt(1).Updated); + Assert.AreEqual(updatedEntity.TestSubEntities.ElementAt(1).Created, updatedEntity.TestSubEntities.ElementAt(1).Updated); + Assert.AreNotEqual(default(DateTime), updatedEntity.TestSubEntity.Created); + Assert.AreNotEqual(default(DateTime), updatedEntity.TestSubEntity.Updated); + Assert.AreEqual(updatedEntity.TestSubEntity.Created, updatedEntity.TestSubEntity.Updated); + } + + [Test] + public async Task SubEntities2() + { + var (oldUpdated, oldCreated) = (_entity.Updated, _entity.Created); + var entity = new TestEntity + { + Id = _entity.Id, + TestSubEntities = new List + { + new TestSubEntity + { + Property = "sub 1" + }, + new TestSubEntity + { + Property = "sub 2" + }, + new TestSubEntity + { + Property = "sub 3" + }, + new TestSubEntity + { + Property = "sub 4" + } + } + }; + + var updatedEntity = await _repository.UpdateAsync(entity); + await _unitOfWork.CommitAsync(); + + updatedEntity.TestSubEntities.First().Property = "salam"; + updatedEntity = await _repository.UpdateAsync(entity); + await _unitOfWork.CommitAsync(); + + Assert.AreNotEqual(default(Guid), updatedEntity.TestSubEntities.ElementAt(0).Id); + Assert.AreNotEqual(default(DateTime), updatedEntity.TestSubEntities.ElementAt(0).Created); + Assert.AreNotEqual(default(DateTime), updatedEntity.TestSubEntities.ElementAt(0).Updated); + } + + #region Add [Test] public async Task AddAddsEntityAndSetsAttributes() diff --git a/Monstarlab.EntityFramework.Extension.Tests/Mocks/TestContext.cs b/Monstarlab.EntityFramework.Extension.Tests/Mocks/TestContext.cs index 3468290..8c6cee3 100644 --- a/Monstarlab.EntityFramework.Extension.Tests/Mocks/TestContext.cs +++ b/Monstarlab.EntityFramework.Extension.Tests/Mocks/TestContext.cs @@ -1,10 +1,33 @@ -namespace Monstarlab.EntityFramework.Extension.Tests.Mocks; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Monstarlab.EntityFramework.Extension.Tests.Mocks; public class TestContext : DbContext { public TestContext(DbContextOptions options) : base(options) { } public DbSet Table { get; set; } + + public DbSet SubEntityTable { get; set; } + public DbSet SingleSubEntityTable { get; set; } public DbSet SoftDeleteTable { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.ApplyConfigurationsFromAssembly(GetType().Assembly); + } + + } + +public class TestEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasOne(e => e.TestSubEntity) + .WithOne(e => e.TestEntity).HasForeignKey(nameof(SingleTestSubEntity)); + } +} \ No newline at end of file diff --git a/Monstarlab.EntityFramework.Extension.Tests/Mocks/TestEntity.cs b/Monstarlab.EntityFramework.Extension.Tests/Mocks/TestEntity.cs index ab201ed..8eb49c3 100644 --- a/Monstarlab.EntityFramework.Extension.Tests/Mocks/TestEntity.cs +++ b/Monstarlab.EntityFramework.Extension.Tests/Mocks/TestEntity.cs @@ -8,4 +8,20 @@ public class TestEntity : EntityBase [ReadOnly(true)] public string ReadOnlyProperty { get; set; } + + public virtual IEnumerable TestSubEntities { get; set; } + + public virtual SingleTestSubEntity TestSubEntity { get; set; } } + +public class TestSubEntity : EntityBase +{ + public string Property { get; set; } + public virtual TestEntity TestEntity { get; set; } +} + +public class SingleTestSubEntity : EntityBase +{ + public string Property { get; set; } + public virtual TestEntity TestEntity { get; set; } +} \ No newline at end of file diff --git a/Monstarlab.EntityFramework.Extension.Tests/Mocks/TestEntityRepository.cs b/Monstarlab.EntityFramework.Extension.Tests/Mocks/TestEntityRepository.cs index 0b8f83f..8ab41a2 100644 --- a/Monstarlab.EntityFramework.Extension.Tests/Mocks/TestEntityRepository.cs +++ b/Monstarlab.EntityFramework.Extension.Tests/Mocks/TestEntityRepository.cs @@ -5,4 +5,4 @@ public class TestEntityRepository : EntityRepository +{ + public TestSubEntityRepository(TestContext context) : base(context) + { + } +} + +public class SingleTestSubEntityRepository : EntityRepository +{ + public SingleTestSubEntityRepository(TestContext context) : base(context) + { + } +} \ No newline at end of file diff --git a/Monstarlab.EntityFramework.Extension/Monstarlab.EntityFramework.Extension.csproj b/Monstarlab.EntityFramework.Extension/Monstarlab.EntityFramework.Extension.csproj index c926fab..55c5590 100644 --- a/Monstarlab.EntityFramework.Extension/Monstarlab.EntityFramework.Extension.csproj +++ b/Monstarlab.EntityFramework.Extension/Monstarlab.EntityFramework.Extension.csproj @@ -15,6 +15,7 @@ Github MIT enable + enable diff --git a/Monstarlab.EntityFramework.Extension/Repositories/BaseEntityRepository.cs b/Monstarlab.EntityFramework.Extension/Repositories/BaseEntityRepository.cs index 424f646..aa14d39 100644 --- a/Monstarlab.EntityFramework.Extension/Repositories/BaseEntityRepository.cs +++ b/Monstarlab.EntityFramework.Extension/Repositories/BaseEntityRepository.cs @@ -1,4 +1,10 @@ -namespace Monstarlab.EntityFramework.Extension.Repositories; +using System.Collections; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.ChangeTracking.Internal; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Monstarlab.EntityFramework.Extension.Utils; + +namespace Monstarlab.EntityFramework.Extension.Repositories; public abstract class BaseEntityRepository : IBaseEntityRepository where TEntity : EntityBase @@ -18,13 +24,10 @@ public virtual async Task AddAsync(TEntity entity) if (entity == null) throw new ArgumentNullException(nameof(entity)); - var now = DateTime.UtcNow; - - entity.Created = now; - entity.Updated = now; - - var addedEntity = await Context.Set().AddAsync(entity); + UpdateSubEntities(Context.Entry(entity), new HashSet(), true); + var addedEntity = await Context.Set().AddAsync(entity); + return addedEntity.Entity; } @@ -47,17 +50,59 @@ public virtual async Task UpdateAsync(TEntity entity) var initialValue = prop.GetValue(originalEntity); var potentialNewValue = prop.GetValue(entity); - if (potentialNewValue != null && potentialNewValue != initialValue && !PropertyIsReadOnly(prop)) + if (potentialNewValue != null && potentialNewValue != initialValue && !prop.IsReadOnly()) prop.SetValue(originalEntity, potentialNewValue); } } + var entry = Context.Entry(originalEntity); + UpdateSubEntities(entry, new HashSet()); + var updatedEntity = Context.Set().Update(originalEntity); return await GetAsync(updatedEntity.Entity.Id); } - private bool PropertyIsReadOnly(PropertyInfo prop) => (prop.GetCustomAttribute(typeof(ReadOnlyAttribute), true) as ReadOnlyAttribute)?.IsReadOnly ?? false; + private void UpdateSubEntities(EntityEntry entry, ISet visited, bool updateSelf = false) + { + if (visited.Contains(entry.Entity)) + return; + + visited.Add(entry.Entity); + + if (updateSelf && entry.Entity.GetType().IsAssignableToGenericType(typeof(EntityBase<>))) + { + entry.DetectChanges(); + if (entry.State is EntityState.Detached or EntityState.Added) + { + entry.State = EntityState.Added; + var now = DateTime.UtcNow; + + entry.Property(nameof(EntityBase.Created)).CurrentValue = now; + entry.Property(nameof(EntityBase.Updated)).CurrentValue = now; + } + else if (entry.State == EntityState.Modified) + { + entry.Property(nameof(EntityBase.Updated)).CurrentValue = DateTime.UtcNow; + } + } + + foreach (var subEntry in entry.Navigations) + { + if (subEntry is CollectionEntry {CurrentValue: { }} entryItems) + { + foreach (var subEntryItem in entryItems.CurrentValue) + { + var subEntryItemEntry = Context.Entry(subEntryItem); + UpdateSubEntities(subEntryItemEntry, visited, true); + } + } + else if (subEntry is ReferenceEntry {TargetEntry: {}} entryItem) + { + UpdateSubEntities(entryItem.TargetEntry, visited, true); + } + } + } public virtual Task DeleteAsync(TEntity entity) { @@ -86,6 +131,7 @@ protected IQueryable Paginate(IQueryable query, [Range(1, int.MaxValue) { if (page < 1) throw new ArgumentException($"{nameof(page)} was below 1. Received: {page}", nameof(page)); + if (pageSize < 1) throw new ArgumentException($"{nameof(pageSize)} was below 1. Received: {pageSize}", nameof(pageSize)); diff --git a/Monstarlab.EntityFramework.Extension/Repositories/EntityRepository.cs b/Monstarlab.EntityFramework.Extension/Repositories/EntityRepository.cs index 95aa7a3..0f0c2ea 100644 --- a/Monstarlab.EntityFramework.Extension/Repositories/EntityRepository.cs +++ b/Monstarlab.EntityFramework.Extension/Repositories/EntityRepository.cs @@ -6,6 +6,9 @@ public class EntityRepository : BaseEntityRepository GetAsync(Expression> where) + => BaseIncludes().FirstOrDefaultAsync(where); + public virtual Task> GetListAsync( [Range(1, int.MaxValue)] int page, [Range(1, int.MaxValue)] int pageSize, diff --git a/Monstarlab.EntityFramework.Extension/Repositories/IEntityRepository.cs b/Monstarlab.EntityFramework.Extension/Repositories/IEntityRepository.cs index 0c723d8..e484186 100644 --- a/Monstarlab.EntityFramework.Extension/Repositories/IEntityRepository.cs +++ b/Monstarlab.EntityFramework.Extension/Repositories/IEntityRepository.cs @@ -9,6 +9,12 @@ public interface IEntityRepository : IBaseEntityRepositoryThe ID of the entity to fetch. Task GetAsync(TId id); + /// + /// Get the entity, filtered by + /// + /// The filter expression + Task GetAsync(Expression> where); + /// /// Get multiple entities paginated. /// diff --git a/Monstarlab.EntityFramework.Extension/Utils/TypeExtensions.cs b/Monstarlab.EntityFramework.Extension/Utils/TypeExtensions.cs new file mode 100644 index 0000000..78a648d --- /dev/null +++ b/Monstarlab.EntityFramework.Extension/Utils/TypeExtensions.cs @@ -0,0 +1,58 @@ +using System.Collections; +using System.Collections.Concurrent; + +namespace Monstarlab.EntityFramework.Extension.Utils; + +internal static class TypeExtensions +{ + private static readonly ConcurrentDictionary TypeDefaults = new(); + + internal static object? GetDefaultValue(this Type type) + { + return type.GetTypeInfo().IsValueType + ? TypeDefaults.GetOrAdd(type, Activator.CreateInstance) + : null; + } + + internal static bool IsAssignableToGenericType(this Type givenType, Type genericType) + { + var interfaceTypes = givenType.GetInterfaces(); + + if (interfaceTypes.Any(it => it.IsGenericType && it.GetGenericTypeDefinition() == genericType)) + { + return true; + } + + if (givenType.IsGenericType && givenType.GetGenericTypeDefinition() == genericType) + { + return true; + } + + if (givenType.BaseType is null) + { + return false; + } + + return IsAssignableToGenericType(givenType.BaseType, genericType); + } +} + +internal static class ReflectionExtensions +{ + internal static object? GetPropertyValue(this object obj, string name) => + obj?.GetType().GetProperty(name)?.GetValue(obj); +} + +internal static class PropertyInfoExtensions +{ + internal static bool IsNavigationProperty(this PropertyInfo prop) => + prop.PropertyType.IsAssignableToGenericType(typeof(EntityBase<>)); + + internal static bool IsCollectionNavigationProperty(this PropertyInfo prop) => + prop.PropertyType.IsAssignableTo(typeof(IEnumerable)) && + prop.PropertyType != typeof(string) && + prop.PropertyType.GenericTypeArguments[0].IsAssignableToGenericType(typeof(EntityBase<>)); + + internal static bool IsReadOnly(this PropertyInfo prop) => + (prop.GetCustomAttribute(typeof(ReadOnlyAttribute), true) as ReadOnlyAttribute)?.IsReadOnly ?? false; +} \ No newline at end of file From bbb02c5e7cfc71ad84521bafd79c24bedf6e493c Mon Sep 17 00:00:00 2001 From: Oleksandr Malomed Date: Wed, 22 Mar 2023 20:31:48 +0200 Subject: [PATCH 2/3] build: upgrade project version to 4.0.0 --- .../Monstarlab.EntityFramework.Extension.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Monstarlab.EntityFramework.Extension/Monstarlab.EntityFramework.Extension.csproj b/Monstarlab.EntityFramework.Extension/Monstarlab.EntityFramework.Extension.csproj index 55c5590..1c9b233 100644 --- a/Monstarlab.EntityFramework.Extension/Monstarlab.EntityFramework.Extension.csproj +++ b/Monstarlab.EntityFramework.Extension/Monstarlab.EntityFramework.Extension.csproj @@ -3,7 +3,7 @@ net6.0 Monstarlab.EntityFramework.Extension - 3.0.2 + 4.0.0 Monstarlab Monstarlab Entity Framework Extension From ad8042a8c3f4db8481df30bd1b0d48e94d160ac7 Mon Sep 17 00:00:00 2001 From: Oleksandr Malomed Date: Wed, 22 Mar 2023 20:36:23 +0200 Subject: [PATCH 3/3] build: upgrade dependencies --- .../Mocks/TestContext.cs | 13 +------------ .../Mocks/TestEntityConfiguration.cs | 12 ++++++++++++ ...onstarlab.EntityFramework.Extension.Tests.csproj | 8 ++++---- .../Monstarlab.EntityFramework.Extension.csproj | 2 +- 4 files changed, 18 insertions(+), 17 deletions(-) create mode 100644 Monstarlab.EntityFramework.Extension.Tests/Mocks/TestEntityConfiguration.cs diff --git a/Monstarlab.EntityFramework.Extension.Tests/Mocks/TestContext.cs b/Monstarlab.EntityFramework.Extension.Tests/Mocks/TestContext.cs index 8c6cee3..5fd39e8 100644 --- a/Monstarlab.EntityFramework.Extension.Tests/Mocks/TestContext.cs +++ b/Monstarlab.EntityFramework.Extension.Tests/Mocks/TestContext.cs @@ -1,6 +1,4 @@ -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Monstarlab.EntityFramework.Extension.Tests.Mocks; +namespace Monstarlab.EntityFramework.Extension.Tests.Mocks; public class TestContext : DbContext { @@ -21,13 +19,4 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } -} - -public class TestEntityConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasOne(e => e.TestSubEntity) - .WithOne(e => e.TestEntity).HasForeignKey(nameof(SingleTestSubEntity)); - } } \ No newline at end of file diff --git a/Monstarlab.EntityFramework.Extension.Tests/Mocks/TestEntityConfiguration.cs b/Monstarlab.EntityFramework.Extension.Tests/Mocks/TestEntityConfiguration.cs new file mode 100644 index 0000000..d61d787 --- /dev/null +++ b/Monstarlab.EntityFramework.Extension.Tests/Mocks/TestEntityConfiguration.cs @@ -0,0 +1,12 @@ +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Monstarlab.EntityFramework.Extension.Tests.Mocks; + +public class TestEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasOne(e => e.TestSubEntity) + .WithOne(e => e.TestEntity).HasForeignKey(nameof(SingleTestSubEntity)); + } +} \ No newline at end of file diff --git a/Monstarlab.EntityFramework.Extension.Tests/Monstarlab.EntityFramework.Extension.Tests.csproj b/Monstarlab.EntityFramework.Extension.Tests/Monstarlab.EntityFramework.Extension.Tests.csproj index 434c117..3c4748d 100644 --- a/Monstarlab.EntityFramework.Extension.Tests/Monstarlab.EntityFramework.Extension.Tests.csproj +++ b/Monstarlab.EntityFramework.Extension.Tests/Monstarlab.EntityFramework.Extension.Tests.csproj @@ -12,14 +12,14 @@ - - + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Monstarlab.EntityFramework.Extension/Monstarlab.EntityFramework.Extension.csproj b/Monstarlab.EntityFramework.Extension/Monstarlab.EntityFramework.Extension.csproj index 1c9b233..5708688 100644 --- a/Monstarlab.EntityFramework.Extension/Monstarlab.EntityFramework.Extension.csproj +++ b/Monstarlab.EntityFramework.Extension/Monstarlab.EntityFramework.Extension.csproj @@ -19,7 +19,7 @@ - +