From c623754755f03ace6ec6b010d8e96eb173f4ce32 Mon Sep 17 00:00:00 2001 From: Xriuk Date: Fri, 26 Apr 2024 13:57:12 +0200 Subject: [PATCH] 3.1.0 release --- docs/_config.yml | 4 + .../collection-mapping-and-projection.md | 5 +- .../CHANGELOG.md | 8 +- .../Internal/EfCoreUtils.cs | 2 +- .../Mappers/AsyncEntityFrameworkCoreMapper.cs | 26 ++-- .../Mappers/EntityFrameworkCoreBaseMapper.cs | 1 - .../Matchers/EntityFrameworkCoreMatcher.cs | 130 +++++++++--------- .../NeatMapper.EntityFrameworkCore.csproj | 22 +-- .../EntityFrameworkCoreProjector.cs | 103 +++++++------- src/NeatMapper.EntityFrameworkCore/README.md | 4 +- src/NeatMapper/CHANGELOG.md | 10 +- src/NeatMapper/NeatMapper.csproj | 13 +- src/NeatMapper/README.md | 4 +- .../DbContextTests.cs | 4 +- .../Mapping/EntityToKeyTests.cs | 25 +++- .../Mapping/KeyToEntityAsyncTests.cs | 9 ++ .../Mapping/KeyToEntityMergeAsyncTests.cs | 9 +- .../Mapping/KeyToEntityMergeTests.cs | 2 +- .../Matching/EntityEntityTests.cs | 28 +++- .../Matching/EntityKeyTests.cs | 24 ++++ ...eatMapper.EntityFrameworkCore.Tests.csproj | 5 +- .../Projection/EntityToKeyTests.cs | 4 +- .../NeatMapper.Tests/NeatMapper.Tests.csproj | 3 +- 23 files changed, 291 insertions(+), 154 deletions(-) diff --git a/docs/_config.yml b/docs/_config.yml index 4deb8ff..d73fb61 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -57,6 +57,10 @@ favicon_ico: "/assets/images/icon.png" search_enabled: true +nav_external_links: + - title: GitHub + url: https://github.com/Xriuk/NeatMapper + back_to_top: true back_to_top_text: "Back to top" diff --git a/docs/advanced-options/collection-mapping-and-projection.md b/docs/advanced-options/collection-mapping-and-projection.md index 50884cf..66fbeb6 100644 --- a/docs/advanced-options/collection-mapping-and-projection.md +++ b/docs/advanced-options/collection-mapping-and-projection.md @@ -64,7 +64,7 @@ public class MyMaps : # Match elements in collections {: .highlight } -The below **does not apply** to projectors. +The section below **does not apply** to projectors. When merging to an existing collection, by default all the object present are removed and new ones are mapped and added (by using `INewMap` or `IMergeMap` in this order). @@ -114,7 +114,8 @@ You can also match whole hierarchies by creating a `IHierarchyMatchMap /// /// - public static readonly MethodInfo EntityEntry_Property = typeof(EntityEntry).GetMethod(nameof(EntityEntry.Property)) + public static readonly MethodInfo EntityEntry_Property = typeof(EntityEntry).GetMethod(nameof(EntityEntry.Property), new[] { typeof(string) }) ?? throw new Exception("Could not find EntityEntry.Property(string)"); /// diff --git a/src/NeatMapper.EntityFrameworkCore/Mappers/AsyncEntityFrameworkCoreMapper.cs b/src/NeatMapper.EntityFrameworkCore/Mappers/AsyncEntityFrameworkCoreMapper.cs index d750fde..7dc71ee 100644 --- a/src/NeatMapper.EntityFrameworkCore/Mappers/AsyncEntityFrameworkCoreMapper.cs +++ b/src/NeatMapper.EntityFrameworkCore/Mappers/AsyncEntityFrameworkCoreMapper.cs @@ -257,22 +257,31 @@ public IAsyncNewMapFactory MapAsyncNewFactory( // Retrieve the db context from the services var dbContext = RetrieveDbContext(mappingOptions); + var dbContextSemaphore = EfCoreUtils.GetOrCreateSemaphoreForDbContext(dbContext); + var retrievalMode = mappingOptions?.GetOptions()?.EntitiesRetrievalMode ?? _entityFrameworkCoreOptions.EntitiesRetrievalMode; var key = _model.FindEntityType(types.To).FindPrimaryKey(); - var dbSet = dbContext.GetType().GetMethods().FirstOrDefault(m => m.IsGenericMethod && m.Name == nameof(DbContext.Set)).MakeGenericMethod(types.To).Invoke(dbContext, null) - ?? throw new InvalidOperationException("Cannot retrieve DbSet"); - var localView = dbSet.GetType().GetProperty(nameof(DbSet.Local)).GetValue(dbSet) as IEnumerable - ?? throw new InvalidOperationException("Cannot retrieve DbSet.Local"); + object dbSet; + IEnumerable localView; + dbContextSemaphore.Wait(); + try { + dbSet = dbContext.GetType().GetMethods().FirstOrDefault(m => m.IsGenericMethod && m.Name == nameof(DbContext.Set)).MakeGenericMethod(types.To).Invoke(dbContext, null) + ?? throw new InvalidOperationException("Cannot retrieve DbSet"); + localView = dbSet.GetType().GetProperty(nameof(DbSet.Local)).GetValue(dbSet) as IEnumerable + ?? throw new InvalidOperationException("Cannot retrieve DbSet.Local"); + } + finally { + dbContextSemaphore.Release(); + } var tupleToValueTupleDelegate = types.From.IsTuple() ? EfCoreUtils.GetOrCreateTupleToValueTupleDelegate(types.From) : null; var keyValuesDelegate = GetOrCreateKeyToValuesDelegate(types.From); - var dbContextSemaphore = EfCoreUtils.GetOrCreateSemaphoreForDbContext(dbContext); - - // Create the matcher (it will never throw because of SafeMatcher/EmptyMatcher) - var normalizedElementsMatcherFactory = GetNormalizedMatchFactory(types, mappingOptions); + // Create the matcher used to retrieve local elements (it will never throw because of SafeMatcher/EmptyMatcher), won't contain semaphore + var normalizedElementsMatcherFactory = GetNormalizedMatchFactory(types, mappingOptions + .ReplaceOrAdd(c => c ?? NestedSemaphoreContext.Instance)); try { // Check if we are mapping a collection or just a single entity if (collectionElementTypes != null) { @@ -327,6 +336,7 @@ public IAsyncNewMapFactory MapAsyncNewFactory( missingEntities .Select(e => keyValuesDelegate.Invoke(e.Key)) .ToArray()); + // Locking shouldn't be needed here because Queryable.Where creates just an Expression.Call var query = Queryable_Where.MakeGenericMethod(types.To).Invoke(null, new object[] { dbSet, filterExpression }) as IQueryable; await dbContextSemaphore.WaitAsync(cancellationToken); diff --git a/src/NeatMapper.EntityFrameworkCore/Mappers/EntityFrameworkCoreBaseMapper.cs b/src/NeatMapper.EntityFrameworkCore/Mappers/EntityFrameworkCoreBaseMapper.cs index 7e24154..fd35794 100644 --- a/src/NeatMapper.EntityFrameworkCore/Mappers/EntityFrameworkCoreBaseMapper.cs +++ b/src/NeatMapper.EntityFrameworkCore/Mappers/EntityFrameworkCoreBaseMapper.cs @@ -17,7 +17,6 @@ using System.Linq.Expressions; using System.Collections.Concurrent; using System.Threading; -using System.Threading.Tasks; using Microsoft.EntityFrameworkCore.ChangeTracking; namespace NeatMapper.EntityFrameworkCore { /// diff --git a/src/NeatMapper.EntityFrameworkCore/Matchers/EntityFrameworkCoreMatcher.cs b/src/NeatMapper.EntityFrameworkCore/Matchers/EntityFrameworkCoreMatcher.cs index e6f0a4e..006cbfa 100644 --- a/src/NeatMapper.EntityFrameworkCore/Matchers/EntityFrameworkCoreMatcher.cs +++ b/src/NeatMapper.EntityFrameworkCore/Matchers/EntityFrameworkCoreMatcher.cs @@ -18,7 +18,7 @@ namespace NeatMapper.EntityFrameworkCore { /// /// /// When working with shadow keys, a will be required. - /// Since a single instance cannot be used concurrently and it is not thread-safe + /// Since a single instance cannot be used concurrently and is not thread-safe /// on its own, every access to the provided instance and all its members /// (local and remote) for each match is protected by a semaphore.
/// This makes this class thread-safe and concurrently usable, though not necessarily efficient to do so.
@@ -32,12 +32,12 @@ public sealed class EntityFrameworkCoreMatcher : IMatcher, IMatcherCanMatch, IMa private readonly IModel _model; /// - /// Type of DbContext to retrieve from . + /// Type of DbContext to retrieve from . Used for shadow keys. /// private readonly Type _dbContextType; /// - /// Service provider used to retrieve instances. + /// Service provider used to retrieve instances. Used for shadow keys. /// private readonly IServiceProvider _serviceProvider; @@ -233,59 +233,41 @@ public IMatchMapFactory MatchFactory( var entityEntryVar = Expression.Variable(typeof(EntityEntry), "entityEntry"); - Expression body; - if (key.Properties.Count == 1) { - // (KeyType)key - var keyExpr = Expression.Convert(keyParam, keyParamType); - - if (key.Properties[0].IsShadowProperty()) { - // (KeyType)entityEntry.Property("Id").CurrentValue == KEY - body = Expression.Equal( - Expression.Convert( + var properties = key.Properties + .Select((p, i) => { + if (p.IsShadowProperty()) { + // (KeyItemType)entityEntry.Property("Key1").CurrentValue + return (Expression)Expression.Convert( Expression.Property( Expression.Call( entityEntryVar, EfCoreUtils.EntityEntry_Property, - Expression.Constant(key.Properties[0].Name)), + Expression.Constant(p.Name)), EfCoreUtils.MemberEntry_CurrentValue), - keyParamType), - keyExpr); - } - else { - // ((EntityType)entity).Id == KEY - body = Expression.Equal(Expression.Property(Expression.Convert(entityParam, entityType), key.Properties[0].PropertyInfo), keyExpr); - } + p.ClrType); + } + else { + // ((EntityType)entity).Key1 + return Expression.PropertyOrField(Expression.Convert(entityParam, entityType), p.Name); + } + }); + Expression body; + if (key.Properties.Count == 1) { + // KEYPROP == (KeyType)key + body = Expression.Equal(properties.Single(), Expression.Convert(keyParam, keyParamType)); } else { - // KEY1 && ... - body = key.Properties - .Select((p, i) => { - // ((KeyType)key).Item1 - var keyExpr = Expression.PropertyOrField(Expression.Convert(keyParam, keyParamType), "Item" + (i + 1)); - - if (p.IsShadowProperty()) { - // (KeyItemType)entityEntry.Property("Key1").CurrentValue == KEY - return Expression.Equal( - Expression.Convert( - Expression.Property( - Expression.Call( - entityEntryVar, - EfCoreUtils.EntityEntry_Property, - Expression.Constant(p.Name)), - EfCoreUtils.MemberEntry_CurrentValue), - p.ClrType), - keyExpr); - } - else { - // ((EntityType)entity).Key1 == KEY - return Expression.Equal(Expression.Property(Expression.Convert(entityParam, entityType), p.PropertyInfo), keyExpr); - } - }) + // KEYPROP1 == ((KeyType)key).Item1 && ... + body = properties + .Select((p, i) => Expression.Equal(p, Expression.PropertyOrField(Expression.Convert(keyParam, keyParamType), "Item" + (i + 1)))) .Aggregate(Expression.AndAlso); } - // If we have a shadow key we must retrieve values from DbContext, so we must use a semaphore (if not already inside one) + // If we have a shadow key we must retrieve values from DbContext, so we must use a semaphore (if not already inside one), + // also we wrap the access to dbContext in a try/catch block to throw map not found if the context is disposed if (_entityShadowKeyCache.GetOrAdd(entityType, __ => key.Properties.Any(p => p.IsShadowProperty()))) { + var catchExceptionParam = Expression.Parameter(typeof(Exception), "e"); + body = Expression.Block(typeof(bool), // if(dbContextSemaphore != null) // dbContextSemaphore.Wait() @@ -303,14 +285,10 @@ public IMatchMapFactory MatchFactory( Expression.Equal(Expression.Property(entityEntryVar, EfCoreUtils.EntityEntry_State), Expression.Constant(EntityState.Detached)), Expression.Throw( Expression.New( - typeof(MatcherException).GetConstructors().Single(), - Expression.New( - typeof(InvalidOperationException).GetConstructor(new[] { typeof(string) }), - Expression.Constant($"The entity of type {entityType.FullName ?? entityType.Name} is not being tracked " + - $"by the provided {nameof(DbContext)}, so its shadow key(s) cannot be retrieved locally. " + - $"Either provide a valid {nameof(DbContext)} or pass a tracked entity.")), - Expression.Constant((sourceType, destinationType)) - ), + typeof(InvalidOperationException).GetConstructor(new[] { typeof(string) }), + Expression.Constant($"The entity of type {entityType.FullName ?? entityType.Name} is not being tracked " + + $"by the provided {nameof(DbContext)}, so its shadow key(s) cannot be retrieved locally. " + + $"Either provide a valid {nameof(DbContext)} or pass a tracked entity.")), body.Type), body) ), @@ -319,7 +297,7 @@ public IMatchMapFactory MatchFactory( Expression.IfThen( Expression.NotEqual(dbContextSemaphoreParam, Expression.Constant(null, dbContextSemaphoreParam.Type)), Expression.Call(dbContextSemaphoreParam, EfCoreUtils.SemaphoreSlim_Release))) - ); + ); } return Expression.Lambda>(body, entityParam, keyParam, dbContextSemaphoreParam, dbContextParam).Compile(); @@ -364,7 +342,21 @@ public IMatchMapFactory MatchFactory( if (tupleToValueTupleDelegate != null) keyObject = tupleToValueTupleDelegate.DynamicInvoke(keyObject); - return entityKeyComparer.Invoke(entityObject, keyObject, dbContextSemaphore, dbContext); + try { + return entityKeyComparer.Invoke(entityObject, keyObject, dbContextSemaphore, dbContext); + } + catch (MapNotFoundException e) { + if (e.From == sourceType && e.To == destinationType) + throw; + else + throw new MappingException(e, (sourceType, destinationType)); + } + catch (OperationCanceledException) { + throw; + } + catch (Exception e) { + throw new MatcherException(e, (sourceType, destinationType)); + } }); } else { @@ -438,14 +430,10 @@ public IMatchMapFactory MatchFactory( Expression.Equal(Expression.Property(entityEntry2Var, EfCoreUtils.EntityEntry_State), Expression.Constant(EntityState.Detached))), Expression.Throw( Expression.New( - typeof(MatcherException).GetConstructors().Single(), - Expression.New( - typeof(InvalidOperationException).GetConstructor(new[] { typeof(string) }), - Expression.Constant($"The entity(ies) of type {sourceType.FullName ?? sourceType.Name} is/are not being tracked " + - $"by the provided {nameof(DbContext)}, so its/their shadow key(s) cannot be retrieved locally. " + - $"Either provide a valid {nameof(DbContext)} or pass a tracked entity(ies).")), - Expression.Constant((sourceType, destinationType)) - ), + typeof(InvalidOperationException).GetConstructor(new[] { typeof(string) }), + Expression.Constant($"The entity(ies) of type {sourceType.FullName ?? sourceType.Name} is/are not being tracked " + + $"by the provided {nameof(DbContext)}, so its/their shadow key(s) cannot be retrieved locally. " + + $"Either provide a valid {nameof(DbContext)} or pass a tracked entity(ies).")), body.Type), body) ), @@ -481,7 +469,21 @@ public IMatchMapFactory MatchFactory( if (source == null || destination == null) return false; - return entityEntityComparer.Invoke(source, destination, dbContextSemaphore, dbContext); + try { + return entityEntityComparer.Invoke(source, destination, dbContextSemaphore, dbContext); + } + catch (MapNotFoundException e) { + if (e.From == sourceType && e.To == destinationType) + throw; + else + throw new MappingException(e, (sourceType, destinationType)); + } + catch (OperationCanceledException) { + throw; + } + catch (Exception e) { + throw new MatcherException(e, (sourceType, destinationType)); + } }); } diff --git a/src/NeatMapper.EntityFrameworkCore/NeatMapper.EntityFrameworkCore.csproj b/src/NeatMapper.EntityFrameworkCore/NeatMapper.EntityFrameworkCore.csproj index 381d3ae..754c72f 100644 --- a/src/NeatMapper.EntityFrameworkCore/NeatMapper.EntityFrameworkCore.csproj +++ b/src/NeatMapper.EntityFrameworkCore/NeatMapper.EntityFrameworkCore.csproj @@ -1,15 +1,16 @@  - net47;net48;netcoreapp3.1;netstandard2.1;net5.0;net6.0;net7.0 + net47;net48;netcoreapp3.1;netstandard2.1;net5.0;net6.0;net7.0;net8.0 enable NeatMapper.EntityFrameworkCore - 2.2.0 + 3.1.0 Xriuk .NEaT Mapper - Entity Framework Core Creates automatic maps and projections between entities and their keys (even composite and shadow keys), supports normal maps and asynchronous ones, also supports collections (not nested). + https://www.neatmapper.org/ef-core/ https://github.com/Xriuk/NeatMapper/tree/main/src/NeatMapper.EntityFrameworkCore - See full changelog at https://github.com/Xriuk/NeatMapper/blob/main/src/NeatMapper.EntityFrameworkCore/CHANGELOG.md#220---2024-02-03 + See full changelog at https://www.neatmapper.org/ef-core/changelog#310---2024-04-26 Xriuk icon.png README.md @@ -25,19 +26,22 @@ - - - + + + - - + + + + + - + diff --git a/src/NeatMapper.EntityFrameworkCore/Projectors/EntityFrameworkCoreProjector.cs b/src/NeatMapper.EntityFrameworkCore/Projectors/EntityFrameworkCoreProjector.cs index 7688c0c..b85b6bf 100644 --- a/src/NeatMapper.EntityFrameworkCore/Projectors/EntityFrameworkCoreProjector.cs +++ b/src/NeatMapper.EntityFrameworkCore/Projectors/EntityFrameworkCoreProjector.cs @@ -1,18 +1,30 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Internal; + +// For IServiceProviderIsService +#if !NET5_0 && !NETCOREAPP3_1 using Microsoft.Extensions.DependencyInjection; +#endif using System; using System.Linq; using System.Linq.Expressions; -using System.Reflection; -using System.Threading; namespace NeatMapper.EntityFrameworkCore { /// - /// which projects entities into their keys, even composite keys - /// as or . + /// which projects entities into their keys (even composite keys + /// as or , and shadow keys). /// + /// + /// When working with shadow keys, a will be required. + /// Since a single instance cannot be used concurrently and is not thread-safe + /// on its own, every access to the provided instance and all its members + /// (local and remote) for each projection is protected by a semaphore.
+ /// This makes this class thread-safe and concurrently usable, though not necessarily efficient to do so.
+ /// Any external concurrent use of the instance is not monitored and could throw exceptions, + /// so you should not be accessing the context externally while projecting. + ///
public sealed class EntityFrameworkCoreProjector : IProjector, IProjectorCanProject { /// /// Db model, shared between instances of the same DbContext type. @@ -20,12 +32,12 @@ public sealed class EntityFrameworkCoreProjector : IProjector, IProjectorCanProj private readonly IModel _model; /// - /// Type of DbContext to retrieve from . + /// Type of DbContext to retrieve from . Used for shadow keys. /// private readonly Type _dbContextType; /// - /// Service provider used to retrieve instances. + /// Service provider used to retrieve instances. Used for shadow keys. /// private readonly IServiceProvider _serviceProvider; @@ -120,9 +132,8 @@ public LambdaExpression Project( k.ClrType ); } - else { - return (Expression)Expression.Property(entityParam, k.PropertyInfo); - } + else + return (Expression)Expression.PropertyOrField(entityParam, k.Name); }); if (key.Properties.Count == 1) { // entity.Id @@ -138,6 +149,8 @@ public LambdaExpression Project( properties); } + var catchExceptionParam = Expression.Parameter(typeof(Exception), "e"); + body = Expression.TryCatch( Expression.Block( new [] { entityEntryVar }, @@ -149,38 +162,27 @@ public LambdaExpression Project( Expression.Assign( entityEntryVar, Expression.Call(dbContextConstant, EfCoreUtils.DbContext_Entry, entityParam)), - // Throws MapNotFoundException if the DbContext is disposed // entity.State == EntityState.Detached ? throw ... : ... Expression.Condition( Expression.Equal(Expression.Property(entityEntryVar, EfCoreUtils.EntityEntry_State), Expression.Constant(EntityState.Detached)), Expression.Throw( Expression.New( - typeof(ProjectionException).GetConstructors().Single(), - Expression.New( - typeof(InvalidOperationException).GetConstructor(new[] { typeof(string) }), - Expression.Constant($"The entity of type {sourceType.FullName ?? sourceType.Name} is not being tracked by the provided context, so its shadow key(s) cannot be retrieved locally.")), - Expression.Constant((sourceType, destinationType)) - ), - body.Type - ), - body - ) - ), + typeof(InvalidOperationException).GetConstructor(new[] { typeof(string) }), + Expression.Constant($"The entity of type {sourceType.FullName ?? sourceType.Name} is not being tracked " + + $"by the provided {nameof(DbContext)}, so its shadow key(s) cannot be retrieved locally. " + + $"Either provide a valid {nameof(DbContext)} or pass a tracked entity.")), + body.Type), + body)), // dbContextSemaphore.Release() - Expression.Call(dbContextSemaphoreConstant, EfCoreUtils.SemaphoreSlim_Release) - ) - ), - Expression.Catch( - typeof(ObjectDisposedException), - Expression.Throw( - Expression.New( - typeof(MapNotFoundException).GetConstructor(new[] { typeof((Type, Type)) }), - Expression.Constant((sourceType, destinationType)) - ), - body.Type - ) - ) - ); + Expression.Call(dbContextSemaphoreConstant, EfCoreUtils.SemaphoreSlim_Release))), + Expression.Catch( + catchExceptionParam, + Expression.Throw( + Expression.New( + typeof(ProjectionException).GetConstructors().Single(), + catchExceptionParam, + Expression.Constant((sourceType, destinationType))), + body.Type))); } else { var properties = key.Properties.Select(k => { @@ -258,28 +260,26 @@ public bool CanProject( if (modelEntity == null || modelEntity.IsOwned()) return false; - // Check that the entity has a key + // Check that the entity has a key and that it matches the key type var key = modelEntity.FindPrimaryKey(); if (key == null || key.Properties.Count < 1) return false; + if (destinationType.IsCompositeKeyType()) { + var keyTypes = destinationType.UnwrapNullable().GetGenericArguments(); + if (key.Properties.Count != keyTypes.Length || !keyTypes.Zip(key.Properties, (k1, k2) => (k1, k2.ClrType)).All(keys => keys.Item1 == keys.Item2)) + return false; + } + else if (key.Properties.Count != 1 || key.Properties[0].ClrType != destinationType.UnwrapNullable()) + return false; // Shadow keys (or partially shadow composite keys) can be projected only if we are not compiling // or we have a db context to retrieve the tracked instances - if(key.Properties.Any(p => p.IsShadowProperty()) && mappingOptions?.GetOptions() != null && + if (key.Properties.Any(p => p.IsShadowProperty()) && mappingOptions?.GetOptions() != null && RetrieveDbContext(mappingOptions) == null) { return false; } - // Check that the key type matches - if (destinationType.IsCompositeKeyType()) { - var keyTypes = destinationType.UnwrapNullable().GetGenericArguments(); - if (key.Properties.Count != keyTypes.Length || !keyTypes.Zip(key.Properties, (k1, k2) => (k1, k2.ClrType)).All(keys => keys.Item1 == keys.Item2)) - return false; - } - else if (key.Properties.Count != 1 || (key.Properties[0].ClrType != destinationType && !destinationType.IsNullable(key.Properties[0].ClrType))) - return false; - return true; #if NETCOREAPP3_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER || NET5_0_OR_GREATER @@ -292,12 +292,13 @@ public bool CanProject( #nullable disable #endif + // Retrieves the DbContext if available, may return null private DbContext RetrieveDbContext(MappingOptions mappingOptions) { var dbContext = mappingOptions?.GetOptions()?.DbContextInstance; if (dbContext != null && dbContext.GetType() != _dbContextType) dbContext = null; - if (dbContext == null) { + if (dbContext == null && mappingOptions?.GetOptions() != null) { try { dbContext = (mappingOptions?.GetOptions()?.ServiceProvider ?? _serviceProvider) .GetService(_dbContextType) as DbContext; @@ -305,6 +306,14 @@ private DbContext RetrieveDbContext(MappingOptions mappingOptions) { catch { } } + if (dbContext == null) { + try { + dbContext = (mappingOptions?.GetOptions()?.ServiceProvider ?? _serviceProvider) + .GetService(_dbContextType) as DbContext; + } + catch { } + } + return dbContext; } diff --git a/src/NeatMapper.EntityFrameworkCore/README.md b/src/NeatMapper.EntityFrameworkCore/README.md index a83da52..224e0f9 100644 --- a/src/NeatMapper.EntityFrameworkCore/README.md +++ b/src/NeatMapper.EntityFrameworkCore/README.md @@ -57,8 +57,8 @@ var myEntitiesKeys = db.Set() ## Advanced options -Find more advanced use cases in the [wiki](https://github.com/Xriuk/NeatMapper/wiki/Entity-Framework-Core) or in the extended [tests project](https://github.com/Xriuk/NeatMapper/tree/main/tests/NeatMapper.EntityFrameworkCore.Tests). +Find more advanced use cases in the [website](https://www.neatmapper.org/ef-core/configuration) or in the extended [tests project](https://github.com/Xriuk/NeatMapper/tree/main/tests/NeatMapper.EntityFrameworkCore.Tests). ## License -[Read the license here](https://github.com/Xriuk/NeatMapper/blob/main/LICENSE.md) +[Read the license here](https://www.neatmapper.org/license) diff --git a/src/NeatMapper/CHANGELOG.md b/src/NeatMapper/CHANGELOG.md index adeea8e..0d9e481 100644 --- a/src/NeatMapper/CHANGELOG.md +++ b/src/NeatMapper/CHANGELOG.md @@ -1,12 +1,16 @@ # Changelog -## [3.1.0] - Unreleased +## [3.1.0] - 2024-04-26 + +### Added + +- .NET 8.0 support ### Fixed - Dependency Injection (DI) now uses `IOptionSnapshot` instead of `IOptions` to respect different lifetimes of mappers/matchers/projectors, previously `IOptions` forced Singleton instead of the specified lifetime - `CanMap*`/`CanMatch`/`CanProject` extension methods now wrap exceptions thrown by the mapper/matcher/projector, while invoking the corresponding `Map*`/`Match`/`Project` methods. An `InvalidOperationException` with the inner exception wrapped will be thrown instead, signaling that the mapper/matcher/projector cannot determine if the two types are supported -- Instead of `TaskCanceledException` which where caught and re-thrown directly by maps and mappers (instead of being wrapped in `MappingException` like the others) now `OperationCanceledException`s are caught and re-thrown, this is backwards compatible, since `TaskCanceledException` is derived from it, but now other exceptions can be caught too +- Instead of `TaskCanceledException` which were caught and re-thrown directly by maps and mappers (instead of being wrapped in `MappingException` like the others) now `OperationCanceledException` are caught and re-thrown, this is backwards compatible, since `TaskCanceledException` is derived from it, but now other exceptions can be caught too ## [3.0.0] - 2024-03-28 @@ -128,7 +132,7 @@ ### Added -- Added support for: +- Support for: - .NET Framework 4.7, 4.8 - .NET Standard 2.1 - .NET Core 3.1 diff --git a/src/NeatMapper/NeatMapper.csproj b/src/NeatMapper/NeatMapper.csproj index f6d09dd..3f2e972 100644 --- a/src/NeatMapper/NeatMapper.csproj +++ b/src/NeatMapper/NeatMapper.csproj @@ -1,15 +1,16 @@  - net47;net48;netcoreapp3.1;netstandard2.1;net5.0;net6.0;net7.0 + net47;net48;netcoreapp3.1;netstandard2.1;net5.0;net6.0;net7.0;net8.0 enable NeatMapper - 3.0.0 + 3.1.0 Xriuk .NEaT Mapper Object mapper and projector, with configurable and reusable mappings. Supports collections, generic types and asynchronous mappings. Also supports projections (expressions). - https://github.com/Xriuk/NeatMapper/tree/main/src/NeatMapper - See full changelog at https://github.com/Xriuk/NeatMapper/blob/main/src/NeatMapper/CHANGELOG.md#300---2024-03-28 + https://www.neatmapper.org + https://github.com/Xriuk/NeatMapper/tree/main/src/NeatMapper + See full changelog at https://www.neatmapper.org/changelog#310---2024-04-26 False LICENSE.md True @@ -34,6 +35,10 @@ + + + + diff --git a/src/NeatMapper/README.md b/src/NeatMapper/README.md index 24d7941..1e88215 100644 --- a/src/NeatMapper/README.md +++ b/src/NeatMapper/README.md @@ -106,8 +106,8 @@ var myBookDtos = db.Set() ## Advanced options -Find more advanced use cases in the [wiki](https://github.com/Xriuk/NeatMapper/wiki) or in the extended [tests project](https://github.com/Xriuk/NeatMapper/tree/main/tests/NeatMapper.Tests). +Find more advanced use cases in the [website](https://www.neatmapper.org/advanced-options/) or in the extended [tests project](https://github.com/Xriuk/NeatMapper/tree/main/tests/NeatMapper.Tests). ## License -[Read the license here](https://github.com/Xriuk/NeatMapper/blob/main/LICENSE.md) +[Read the license here](https://www.neatmapper.org/license) diff --git a/tests/NeatMapper.EntityFrameworkCore.Tests/DbContextTests.cs b/tests/NeatMapper.EntityFrameworkCore.Tests/DbContextTests.cs index c8dfb1e..6eb8c78 100644 --- a/tests/NeatMapper.EntityFrameworkCore.Tests/DbContextTests.cs +++ b/tests/NeatMapper.EntityFrameworkCore.Tests/DbContextTests.cs @@ -47,7 +47,7 @@ private static WeakReference RunScope(IServiceProvider serviceProvider) { var mapper = scope.ServiceProvider.GetRequiredService(); mapper.Map(2); - var conditionalWeakTable = typeof(EntityFrameworkCoreBaseMapper).GetField("_dbContextSemaphores", BindingFlags.NonPublic | BindingFlags.Static).GetValue(null) as IEnumerable; + var conditionalWeakTable = typeof(EfCoreUtils).GetField("_dbContextSemaphores", BindingFlags.NonPublic | BindingFlags.Static).GetValue(null) as IEnumerable; var contextKey = conditionalWeakTable.Cast().First(); var context = contextKey.GetType().GetProperty(nameof(KeyValuePair.Key)).GetValue(contextKey) as TestContext; Assert.AreEqual("Test", context.Tag); @@ -76,7 +76,7 @@ public void ShouldDisposeSemaphoreAftedDbContextIsDisposed() { Assert.IsFalse(dbContextRef.IsAlive); // Semaphores should be destroyed - var conditionalWeakTable = typeof(EntityFrameworkCoreBaseMapper).GetField("_dbContextSemaphores", BindingFlags.NonPublic | BindingFlags.Static).GetValue(null) as IEnumerable; + var conditionalWeakTable = typeof(EfCoreUtils).GetField("_dbContextSemaphores", BindingFlags.NonPublic | BindingFlags.Static).GetValue(null) as IEnumerable; Assert.AreEqual(0, conditionalWeakTable.Cast().Count()); } } diff --git a/tests/NeatMapper.EntityFrameworkCore.Tests/Mapping/EntityToKeyTests.cs b/tests/NeatMapper.EntityFrameworkCore.Tests/Mapping/EntityToKeyTests.cs index b92eb7f..808b986 100644 --- a/tests/NeatMapper.EntityFrameworkCore.Tests/Mapping/EntityToKeyTests.cs +++ b/tests/NeatMapper.EntityFrameworkCore.Tests/Mapping/EntityToKeyTests.cs @@ -215,7 +215,7 @@ public void ShouldNotMapEntitiesToShadowKeysWithContextIfNotTracked() { var exc = Assert.ThrowsException(() => _mapper.Map(entity, options)); Assert.IsInstanceOfType(exc.InnerException, typeof(InvalidOperationException)); - Assert.AreEqual($"The entity of type {typeof(ShadowIntKey).FullName} is not being tracked by the provided context, so its shadow key(s) cannot be retrieved locally.", exc.InnerException.Message); + Assert.IsTrue(exc.InnerException?.Message.StartsWith($"The entity of type {typeof(ShadowIntKey).FullName} is not being tracked by the provided {nameof(DbContext)}")); } [TestMethod] @@ -395,5 +395,28 @@ public void ShouldNotMapEntitiesCollectionToCompositeKeysCollectionIfOrderIsWron TestUtils.AssertMapNotFound(() => _mapper.Map<(string, int)?[]>(new[] { new CompositeClassKey { Id1 = 2, Id2 = "Test" } })); } } + + [TestMethod] + public void ShouldThrowMappingExceptionIfDbContextIsDisposed() { + var db = new TestContext(_serviceProvider.GetRequiredService>()); + db.Database.EnsureDeleted(); + db.Database.EnsureCreated(); + var entity = new ShadowIntKey(); + db.Add(entity); + db.SaveChanges(); + + var options = new object[] { new EntityFrameworkCoreMappingOptions(dbContextInstance: db) }; + + Assert.IsTrue(_mapper.CanMapNew(options)); + + Assert.AreEqual(1, _mapper.Map(entity, options)); + + db.Dispose(); + + Assert.IsTrue(_mapper.CanMapNew(options)); + + var exc = Assert.ThrowsException(() => _mapper.Map(entity, options)); + Assert.IsInstanceOfType(exc.InnerException, typeof(ObjectDisposedException)); + } } } diff --git a/tests/NeatMapper.EntityFrameworkCore.Tests/Mapping/KeyToEntityAsyncTests.cs b/tests/NeatMapper.EntityFrameworkCore.Tests/Mapping/KeyToEntityAsyncTests.cs index 62f08c6..76347d4 100644 --- a/tests/NeatMapper.EntityFrameworkCore.Tests/Mapping/KeyToEntityAsyncTests.cs +++ b/tests/NeatMapper.EntityFrameworkCore.Tests/Mapping/KeyToEntityAsyncTests.cs @@ -63,6 +63,10 @@ public void Initialize() { _db.Add(new CompositePrimitiveKey { Id1 = 2, Id2 = new Guid("56033406-E593-4076-B48A-70988C9F9190") }); _db.Add(new CompositeClassKey { Id1 = 2, Id2 = "Test" }); _db.Add(new ShadowIntKey()); + var comp = _db.Add(new ShadowCompositeKey { + Id1 = 2 + }); + comp.Property("Id2").CurrentValue = "Test"; _db.SaveChanges(); #if NET5_0_OR_GREATER @@ -227,6 +231,11 @@ public async Task ShouldMapEntitiesWithShadowKeys() { Assert.IsTrue(await _mapper.CanMapAsyncNew()); Assert.IsNotNull(await _mapper.MapAsync(1)); + + + Assert.IsTrue(await _mapper.CanMapAsyncNew, ShadowCompositeKey>()); + + Assert.IsNotNull(await _mapper.MapAsync(Tuple.Create(2, "Test"))); } [TestMethod] diff --git a/tests/NeatMapper.EntityFrameworkCore.Tests/Mapping/KeyToEntityMergeAsyncTests.cs b/tests/NeatMapper.EntityFrameworkCore.Tests/Mapping/KeyToEntityMergeAsyncTests.cs index 30cb410..709c384 100644 --- a/tests/NeatMapper.EntityFrameworkCore.Tests/Mapping/KeyToEntityMergeAsyncTests.cs +++ b/tests/NeatMapper.EntityFrameworkCore.Tests/Mapping/KeyToEntityMergeAsyncTests.cs @@ -191,7 +191,7 @@ public async Task ShouldMapNullableCompositeKeyToEntity() { } [TestMethod] - public async Task ShouldNotMapCompositeKeyToEntitysIfOrderIsWrong() { + public async Task ShouldNotMapCompositeKeyToEntityIfOrderIsWrong() { // Tuple { Assert.IsFalse(await _mapper.CanMapAsyncMerge, CompositePrimitiveKey>()); @@ -218,10 +218,15 @@ public async Task ShouldNotMapCompositeKeyToEntitysIfOrderIsWrong() { } [TestMethod] - public async Task ShouldMapEntitiesWithShadowKeys() { + public async Task ShouldMapShadowKeyToEntities() { Assert.IsTrue(await _mapper.CanMapAsyncMerge()); Assert.IsNotNull(await _mapper.MapAsync(1, (ShadowIntKey)null)); + + + Assert.IsTrue(await _mapper.CanMapAsyncMerge, ShadowCompositeKey>()); + + Assert.IsNotNull(await _mapper.MapAsync(Tuple.Create(2, "Test"), (ShadowCompositeKey)null)); } [TestMethod] diff --git a/tests/NeatMapper.EntityFrameworkCore.Tests/Mapping/KeyToEntityMergeTests.cs b/tests/NeatMapper.EntityFrameworkCore.Tests/Mapping/KeyToEntityMergeTests.cs index 1368711..3be3d24 100644 --- a/tests/NeatMapper.EntityFrameworkCore.Tests/Mapping/KeyToEntityMergeTests.cs +++ b/tests/NeatMapper.EntityFrameworkCore.Tests/Mapping/KeyToEntityMergeTests.cs @@ -190,7 +190,7 @@ public void ShouldMapNullableCompositeKeyToEntity() { } [TestMethod] - public void ShouldNotMapCompositeKeyToEntitysIfOrderIsWrong() { + public void ShouldNotMapCompositeKeyToEntityIfOrderIsWrong() { // Tuple { Assert.IsFalse(_mapper.CanMapMerge, CompositePrimitiveKey>()); diff --git a/tests/NeatMapper.EntityFrameworkCore.Tests/Matching/EntityEntityTests.cs b/tests/NeatMapper.EntityFrameworkCore.Tests/Matching/EntityEntityTests.cs index ec287fa..f212006 100644 --- a/tests/NeatMapper.EntityFrameworkCore.Tests/Matching/EntityEntityTests.cs +++ b/tests/NeatMapper.EntityFrameworkCore.Tests/Matching/EntityEntityTests.cs @@ -113,7 +113,7 @@ public void ShouldMatchEntitiesWithShadowKeysWithContextIfTracked() { var options = new object[] { new EntityFrameworkCoreMappingOptions(dbContextInstance: db) }; - Assert.IsTrue(_matcher.CanMatch(options)); + Assert.IsTrue(_matcher.CanMatch(options)); Assert.IsTrue(_matcher.Match(entity1, entity1, options)); Assert.IsFalse(_matcher.Match(entity1, entity2, options)); @@ -164,5 +164,31 @@ public void ShouldNotLockSemaphoreInsideNestedSemaphoreContext() { semaphore.Release(); } } + + [TestMethod] + public void ShouldThrowMatcherExceptionIfDbContextIsDisposed() { + var db = new TestContext(_serviceProvider.GetRequiredService>()); + db.Database.EnsureDeleted(); + db.Database.EnsureCreated(); + var entity1 = new ShadowIntKey(); + db.Add(entity1); + var entity2 = new ShadowIntKey(); + db.Add(entity2); + db.SaveChanges(); + + var options = new object[] { new EntityFrameworkCoreMappingOptions(dbContextInstance: db) }; + + Assert.IsTrue(_matcher.CanMatch(options)); + + Assert.IsTrue(_matcher.Match(entity1, entity1, options)); + Assert.IsFalse(_matcher.Match(entity1, entity2, options)); + + db.Dispose(); + + Assert.IsTrue(_matcher.CanMatch(options)); + + var exc = Assert.ThrowsException(() => _matcher.Match(entity1, entity1, options)); + Assert.IsInstanceOfType(exc.InnerException, typeof(ObjectDisposedException)); + } } } diff --git a/tests/NeatMapper.EntityFrameworkCore.Tests/Matching/EntityKeyTests.cs b/tests/NeatMapper.EntityFrameworkCore.Tests/Matching/EntityKeyTests.cs index ff11d09..2540259 100644 --- a/tests/NeatMapper.EntityFrameworkCore.Tests/Matching/EntityKeyTests.cs +++ b/tests/NeatMapper.EntityFrameworkCore.Tests/Matching/EntityKeyTests.cs @@ -284,5 +284,29 @@ public void ShouldNotLockSemaphoreInsideNestedSemaphoreContext() { semaphore.Release(); } } + + [TestMethod] + public void ShouldThrowMatcherExceptionIfDbContextIsDisposed() { + var db = new TestContext(_serviceProvider.GetRequiredService>()); + db.Database.EnsureDeleted(); + db.Database.EnsureCreated(); + var entity = new ShadowIntKey(); + db.Add(entity); + db.SaveChanges(); + + var options = new object[] { new EntityFrameworkCoreMappingOptions(dbContextInstance: db) }; + + Assert.IsTrue(_matcher.CanMatch(options)); + + Assert.IsTrue(_matcher.Match(entity, 1, options)); + Assert.IsFalse(_matcher.Match(entity, 2, options)); + + db.Dispose(); + + Assert.IsTrue(_matcher.CanMatch(options)); + + var exc = Assert.ThrowsException(() => _matcher.Match(entity, 1, options)); + Assert.IsInstanceOfType(exc.InnerException, typeof(ObjectDisposedException)); + } } } diff --git a/tests/NeatMapper.EntityFrameworkCore.Tests/NeatMapper.EntityFrameworkCore.Tests.csproj b/tests/NeatMapper.EntityFrameworkCore.Tests/NeatMapper.EntityFrameworkCore.Tests.csproj index 30c627b..ac049d7 100644 --- a/tests/NeatMapper.EntityFrameworkCore.Tests/NeatMapper.EntityFrameworkCore.Tests.csproj +++ b/tests/NeatMapper.EntityFrameworkCore.Tests/NeatMapper.EntityFrameworkCore.Tests.csproj @@ -1,7 +1,7 @@  - net47;net48;netcoreapp3.1;net5.0;net6.0;net7.0 + net47;net48;netcoreapp3.1;net5.0;net6.0;net7.0;net8.0 false true @@ -21,6 +21,9 @@ + + + diff --git a/tests/NeatMapper.EntityFrameworkCore.Tests/Projection/EntityToKeyTests.cs b/tests/NeatMapper.EntityFrameworkCore.Tests/Projection/EntityToKeyTests.cs index 2c868bb..c7c8075 100644 --- a/tests/NeatMapper.EntityFrameworkCore.Tests/Projection/EntityToKeyTests.cs +++ b/tests/NeatMapper.EntityFrameworkCore.Tests/Projection/EntityToKeyTests.cs @@ -99,7 +99,9 @@ public void ShouldThrowIfDbContextDisposedInProjectEntityToShadowKeyCompilable() var map = _projector.Project(options, ProjectionCompilationContext.Instance); var deleg = map.Compile(); - Assert.ThrowsException(() => deleg.Invoke(new ShadowStringKey())); + + var exc = Assert.ThrowsException(() => deleg.Invoke(new ShadowStringKey())); + Assert.IsInstanceOfType(exc.InnerException, typeof(ObjectDisposedException)); } [TestMethod] diff --git a/tests/NeatMapper.Tests/NeatMapper.Tests.csproj b/tests/NeatMapper.Tests/NeatMapper.Tests.csproj index 8d97e64..bab060b 100644 --- a/tests/NeatMapper.Tests/NeatMapper.Tests.csproj +++ b/tests/NeatMapper.Tests/NeatMapper.Tests.csproj @@ -1,7 +1,7 @@  - net47;net48;netcoreapp3.1;net5.0;net6.0;net7.0 + net47;net48;netcoreapp3.1;net5.0;net6.0;net7.0;net8.0 false true @@ -12,6 +12,7 @@ +