Open Closed

Automapper update collection #7581


0
fvdh created

Hi,

We have a product entity which has a collection with specifications like this:

    [Table("Masterdata.Products")]
    public class Product : FullAuditedEntity, IMustHaveTenant
    {
        public int TenantId { get; set; }

        [Required]
        public string Code { get; set; }

        public ICollection<ProductSpecificationValue> Specifications { get; set; }
    }

We want to update the product trough following DTO:

    public class ProductCreateOrEditDto : EntityDto<int?>
    {
        [Required]
        public string Code { get; set; }

        public ICollection<ProductSpecificationValueCreateOrEditDto> Specifications { get; set; }
    }

Following code we want to use to update the product:

public async Task Update(ProductCreateOrEditDto input)
        {
            var product = await _productRepository.GetAllIncluding(e => e.Specifications).Where(e => e.Id == input.Id).FirstOrDefaultAsync();
            ObjectMapper.Map(input, product);
        }

We can't update the values in the specification as the objectmapper won't sync the collection. The collection in the product gets deleted and AutoMapper creates new instances for the specification in the input object. Referring to following github topic i would assume that Automapper would sync the collections based on the EF properties. https://github.com/aspnetboilerplate/aspnetboilerplate/pull/4034

Do we need to add some configuration to make this work? Are we missing something else?


13 Answer(s)
  • 0
    maliming created

    I recommend that you add/delete/update the Specifications collection separately.

  • 0
    fvdh created

    That's a way to do it but it feels to much like a workaround. In the example I simplified the entity, in the original code we have 5 more collections in the enitty. If we need to handle each collection with a separately handling we get a lot of messy code.

    The AutoMapper.Collection is specially made for supporting collections and is also implemented in Abp. So i guess it's only matter of finding out the correct configuration.

  • 0
    maliming created

    What is product framework type (.net framework or .net core)?

  • 0
    fvdh created

    The product type is ASP.NET CORE & Angular.

  • 0
    maliming created

    @fvdh Can you share your Automapper configuration code?

    This is my configuration and test code.

    public class Thing
    {
    	public int Id { get; set; }
    	public string Title { get; set; }
    
    	public string Code { get; set; }
    }
    
    public class ThingDto
    {
    	public int Id { get; set; }
    	public string Title { get; set; }
    }
    
    class Program
    {
    	static void Main(string[] args)
    	{
    		Mapper.Initialize(cfg =>
    		{
    			cfg.AddCollectionMappers();
    			cfg.CreateMap<ThingDto, Thing>().EqualityComparison((dto, entity) => dto.Id == entity.Id).ReverseMap();
    		});
    
    		var dtos = new List<ThingDto>
    		{
    			new ThingDto { Id = 1, Title = "test1" },
    			new ThingDto { Id = 2, Title = "test2" }
    		};
    
    
    		var entities = new List<Thing>
    		{
    			new Thing { Id = 1, Title = "" , Code = "1"},
    			new Thing { Id = 2, Title = "" , Code = "2"}
    		};
    
    		Mapper.Map(dtos, entities);
    
    		entities[0].Id.ShouldBe(1);
    		entities[0].Title.ShouldBe("test1");
    		entities[0].Code.ShouldBe("1");
    
    		entities[1].Id.ShouldBe(2);
    		entities[1].Title.ShouldBe("test2");
    		entities[1].Code.ShouldBe("2");
    	}
    }
    
  • 0
    fvdh created

    Sorry for the late answer, I haven't got any notice of the reply.

    The code you provided runs fine but doesn't proves anything. The problem is that AutoMapper creates new entities for the DTO's instead of updating the current DTO objects.

    I've changed your Entity and DTO classes to FullAudited and checkes for the creation time. When you run that test you will see that the CreationTime get changed.

            public class Thing : FullAuditedEntity
            {
                public string Title { get; set; }
    
                public string Code { get; set; }
            }
    
            public class ThingDto : FullAuditedEntityDto
            {
                public string Title { get; set; }
            }
    
            [Fact]
            public async Task Should_Update_Collections()
            {
                Mapper.Initialize(cfg =>
                {
                    cfg.AddCollectionMappers();
                    cfg.CreateMap<ThingDto, Thing>().EqualityComparison((dto, entity) => dto.Id == entity.Id).ReverseMap();
                });
    
                var dtos = new List<ThingDto>
                {
                    new ThingDto { Id = 1, Title = "test1" },
                    new ThingDto { Id = 2, Title = "test2" }
                };
    
                var entities = new List<Thing>
                {
                    new Thing { Id = 1, Title = "" , Code = "1"},
                    new Thing { Id = 2, Title = "" , Code = "2"}
                };
    
                DateTime tCreationTime = entities[0].CreationTime;
    
                Mapper.Map(dtos, entities);
    
                entities[0].Id.ShouldBe(1);
                entities[0].Title.ShouldBe("test1");
                entities[0].Code.ShouldBe("1");
    
                entities[0].CreationTime.ShouldBe(tCreationTime);
    
                entities[1].Id.ShouldBe(2);
                entities[1].Title.ShouldBe("test2");
                entities[1].Code.ShouldBe("2");
            }
        }
    
  • 0
    maliming created

    hi @fvdh

    The reason for the following test failure is because the CreationTime property is also available in dto and is set to the current time in the constructor.

    entities[0].CreationTime.ShouldBe(tCreationTime);
    

    You can create an example using Zero's Demo project (simply reproduce the above automapper problem), send it to me, I will view and write some code for you. : )

    Email: liming.ma@volosoft.com

  • 0
    fvdh created

    hi @maliming

    I've just send you the example via WeTransfer. A new application service 'ProductAppService' is added with 2 services.

    The service that failes:

    1. Get the product and map it to a DTO
    2. Change the specifications in DTO object
    3. Maps it back to the product object

    This fails with following error:

    The instance of entity type 'ProductSpecification' cannot be tracked because another instance with the same key value for {'Id'} is already being tracked. When attaching existing entities, ensure that only one entity instance with a given key value is attached. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see the conflicting key values.
    System.InvalidOperationException: The instance of entity type 'ProductSpecification' cannot be tracked because another instance with the same key value for {'Id'} is already being tracked. When attaching existing entities, ensure that only one entity instance with a given key value is attached. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see the conflicting key values.
       at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.IdentityMap`1.ThrowIdentityConflict(InternalEntityEntry entry)
       at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.IdentityMap`1.Add(TKey key, InternalEntityEntry entry, Boolean updateDuplicate)
       at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.StartTracking(InternalEntityEntry entry)
       at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntityEntry.SetEntityState(EntityState oldState, EntityState newState, Boolean acceptChanges)
       at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.EntityGraphAttacher.PaintAction(EntityEntryGraphNode node, Boolean force)
       at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.EntityEntryGraphIterator.TraverseGraph[TState](EntityEntryGraphNode node, TState state, Func`3 handleNode)
       at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.NavigationFixer.NavigationCollectionChanged(InternalEntityEntry entry, INavigation navigation, IEnumerable`1 added, IEnumerable`1 removed)
       at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntityEntryNotifier.NavigationCollectionChanged(InternalEntityEntry entry, INavigation navigation, IEnumerable`1 added, IEnumerable`1 removed)
       at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.ChangeDetector.DetectNavigationChange(InternalEntityEntry entry, INavigation navigation)
       at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.ChangeDetector.DetectChanges(InternalEntityEntry entry)
       at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.ChangeDetector.DetectChanges(IStateManager stateManager)
       at Microsoft.EntityFrameworkCore.ChangeTracking.ChangeTracker.Entries()
       at Abp.Zero.EntityFrameworkCore.AbpZeroCommonDbContext`3.SaveChangesAsync(CancellationToken cancellationToken)
       at Abp.EntityFrameworkCore.Uow.EfCoreUnitOfWork.SaveChangesInDbContextAsync(DbContext dbContext) in D:\Github\aspnetboilerplate\src\Abp.EntityFrameworkCore\EntityFrameworkCore\Uow\EfCoreUnitOfWork.cs:line 167
       at Abp.EntityFrameworkCore.Uow.EfCoreUnitOfWork.SaveChangesAsync() in D:\Github\aspnetboilerplate\src\Abp.EntityFrameworkCore\EntityFrameworkCore\Uow\EfCoreUnitOfWork.cs:line 68
       at Abp.EntityFrameworkCore.Uow.EfCoreUnitOfWork.CompleteUowAsync() in D:\Github\aspnetboilerplate\src\Abp.EntityFrameworkCore\EntityFrameworkCore\Uow\EfCoreUnitOfWork.cs:line 83
       at Abp.Domain.Uow.UnitOfWorkBase.CompleteAsync() in D:\Github\aspnetboilerplate\src\Abp\Domain\Uow\UnitOfWorkBase.cs:line 273
       at Abp.AspNetCore.Mvc.Uow.AbpUowActionFilter.OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) in D:\Github\aspnetboilerplate\src\Abp.AspNetCore\AspNetCore\Mvc\Uow\AbpUowActionFilter.cs:line 49
       at Microsoft.AspNetCore.Mvc.Internal.ControllerActionInvoker.InvokeNextActionFilterAsync()
       at Microsoft.AspNetCore.Mvc.Internal.ControllerActionInvoker.Rethrow(ActionExecutedContext context)
       at Microsoft.AspNetCore.Mvc.Internal.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
       at Microsoft.AspNetCore.Mvc.Internal.ControllerActionInvoker.InvokeInnerFilterAsync()
       at Microsoft.AspNetCore.Mvc.Internal.ResourceInvoker.InvokeNextExceptionFilterAsync()
    

    The second service succeed without error when changing the Specifications directly in the Product object instead of mapping it to a DTO object.

    This scenario tells me that something is wrong with the mapping from ProductDTO to Product. I hope you guys can point me to the right direction.

  • 0
    maliming created

    Thanks, I will check it out.

  • 0
    maliming created

    hi @fvdh Please provide a email address, I will send you the code for ProjectDemo. It can already work with automapper. See git commit for specific changes.

  • 0
    fvdh created

    hi @maliming,

    You can send it to f.vanderhaegen@codefined.be .

    Thx.

  • 0
    maliming created

    Email has been sent.

  • 0
    fvdh created

    @hi maliming

    I just want to inform you that the problem is solved based on the sent project. The code you've sent was a good base to start from. I found some other issues in the code which were also responsible for not finding the right solution.

    Thanks for helping me out!