Search code examples
entity-framework-coreautomapper

How to map only changed properties using Automapper and Entity Framework


I know that this question had been asked already, but those answers didn't work for me. So, I beg not to close this question! 😎

The goal is simple:

  1. Get entity from database using Entity Framework.
  2. Pass this entity to MVC view.
  3. Edit entity on the page.
  4. Save entity.

What I have:

Entities

Client relates to table in database.

ClientDto is the same as Client, but without Comment property (to emulate situation when user must not see this field).

public class Client
{
  public string TaskNumber { get; set; }
  public DateTime CrmRegDate { get; set; }
  public string Comment { get; set; }
}

public class ClientDto
{
  public string TaskNumber { get; set; }
  public DateTime CrmRegDate { get; set; }
}

Database table

CREATE TABLE dbo.client
(
  task_number  varchar(50)   NOT NULL,
  crm_reg_date date          NOT NULL,
  comment      varchar(3000)     NULL
);

-- Seeding
INSERT INTO dbo.client VALUES
('1001246', '2010-09-14', 'comment 1'),
('1001364', '2010-09-14', 'comment 2'),
('1002489', '2010-09-22', 'comment 3');

MVC controller

public class ClientsController : Controller
{
  private readonly ILogger logger;
  private readonly TestContext db;
  private IMapper mapper;

  public ClientsController(TestContext db, IMapper mapper, ILogger<ClientsController> logger)
  {
    this.db = db;
    this.logger = logger;
    this.mapper = mapper;
  }

  public IActionResult Index()
  {
    var clients = db.Clients;
    var clientsDto = mapper.ProjectTo<ClientDto>(clients);
    return View(model: clientsDto);
  }

  public async Task<IActionResult> Edit(string taskNumber)
  {
    var client = await db.Clients.FindAsync(taskNumber);
    return View(model: client);
  }

  [HttpPost]
  public async Task<IActionResult> Edit(ClientDto clientDto)
  {
    // For now it's empty - it'll be used for saving entity
  }
}

Mappings:

builder.Services
  .AddAutoMapper(config =>
  {
    config.CreateMap<Client, ClientDto>();
    config.CreateMap<ClientDto, Client>();
  }

Helper (outputs entity entry as JSON)

internal static class EntititesExtensions
{
  internal static string ToJson<TEntity>(this EntityEntry<TEntity> entry) where TEntity: class
  {
    var states = from member in entry.Members
                 select new
                 {
                   State = Enum.GetName(member.EntityEntry.State),
                   Name = member.Metadata.Name,
                   Value = member.CurrentValue
                 };
    var json = JsonSerializer.SerializeToNode(states);
    return json.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
  }
}

THE PROBLEM

I need to map only changed properties from ClientDto to Client in Edit(ClientDto clientDto).

Which solutions were used

Solution 1

Just map ClientDto to Client:

[HttpPost]
public async Task<IActionResult> Edit(ClientDto clientDto)
{
  var client = mapper.Map<Client>(clientDto);
  logger.LogInformation(db.Entry(client).ToJson());
  db.Update(client);
  await db.SaveChangesAsync();
  return View(viewName: "Success");
}

The problem with this code is that absolutely new Client entity gets created which properties will be filled by ClientDto. This means that Comment property will be NULL and it will make NULL the column comment in database. In general case, all hidden properties (i.e. absent in DTO) will have their default values. Not good.

Output:

[
  {
    "State": "Detached",
    "Name": "TaskNumber",
    "Value": "1001246"
  },
  {
    "State": "Detached",
    "Name": "Comment",
    "Value": null
  },
  {
    "State": "Detached",
    "Name": "CrmRegDate",
    "Value": "2010-09-15T00:00:00"
  }
]

Solution 2

I tried to use the solution from the answer I mentioned above:

public static IMappingExpression<TSource, TDestination> MapOnlyIfChanged<TSource, TDestination>(this IMappingExpression<TSource, TDestination> map)
{
  map.ForAllMembers(source =>
  {
    source.Condition((sourceObject, destObject, sourceProperty, destProperty) =>
    {
      if (sourceProperty == null)
        return !(destProperty == null);
      return !sourceProperty.Equals(destProperty);
    });
  });
  return map;
}

In configuration:

builder.Services
  .AddAutoMapper(config =>
  {
    config.CreateMap<Client, ClientDto>();
    config.CreateMap<ClientDto, Client>().MapOnlyIfChanged();
  });

Running same code as in solution 1, we get the same output (Comment is null):

[
  {
    "State": "Detached",
    "Name": "TaskNumber",
    "Value": "1001246"
  },
  {
    "State": "Detached",
    "Name": "Comment",
    "Value": null
  },
  {
    "State": "Detached",
    "Name": "CrmRegDate",
    "Value": "2010-09-15T00:00:00"
  }
]

Not good.

Solution 3

Let's take another route:

  1. Obtain entity from database.
  2. Use non-generic Map in order to overwrite the values from ClientDto to Client from database.
  3. Tune mapping configuration in order to skip properties which values are equal.
builder.Services
  .AddAutoMapper(config =>
  {
    config.CreateMap<Client, ClientDto>();
    config.CreateMap<ClientDto, Client>()
      .ForMember(
        dest => dest.TaskNumber,
        opt => opt.Condition((src, dest) => src.TaskNumber != dest.TaskNumber))
      .ForMember(
        dest => dest.TaskNumber,
        opt => opt.Condition((src, dest) => src.CrmRegDate != dest.CrmRegDate));
  });
[HttpPost]
public async Task<IActionResult> Edit(ClientDto clientDto)
{
  var dbClient = await db.Clients.FindAsync(clientDto.TaskNumber);
  logger.LogInformation(db.Entry(dbClient).ToJson());
  mapper.Map(
    source: clientDto,
    destination: dbClient,
    sourceType: typeof(ClientDto),
    destinationType: typeof(Client)
  );
  logger.LogInformation(db.Entry(dbClient).ToJson());
  db.Update(dbClient);
  await db.SaveChangesAsync();
  return View(viewName: "Success");
}

This finally works, but it has problem - it still modifies all properties. Here's the output:

[
  {
    "State": "Modified",
    "Name": "TaskNumber",
    "Value": "1001246"
  },
  {
    "State": "Modified",
    "Name": "Comment",
    "Value": "comment 1"
  },
  {
    "State": "Modified",
    "Name": "CrmRegDate",
    "Value": "2010-09-15T00:00:00"
  }
]

So, how to make Automapper update only modified properties? 😐


Solution

  • You do not need to call context.Update() explicitly.

    When loading entity, EF remember every original values for every mapped property.

    Then when you change property, EF will compare current properties with original and create appropriate update SQL only for changed properties.

    For further reading: Change Tracking in EF Core