Making AddOrUpdate change only some properties

2020-03-12 06:58发布

This might be a simple question, however I'm new to Code First and Migrations so bear with me. I'll keep sample code to a minimum to show the problem:

I have a BaseAuditableEntity which includes this (among other things, but let's simplify):

public abstract class BaseAuditableEntity : BaseEntity, IAuditableEntity
{
  public DateTime CreatedOn { get; set; }
  public DateTime LastModified { get; set; }
}

Now a (for example) User POCO inherits from it:

public class User : BaseAuditableEntity
{
  public string UserName { get; set; }
  public string PasswordHash { get; set; }
  public string FullName { get; set; }
  public string Email { get; set; }
  public bool Active { get; set; }
  public DateTime? LastLogin { get; set; }
}

I have this on my context's SaveChanges method, to fill in the CreatedOn and LastModified dates (simplified):

public override int SaveChanges()
{
  var changeSet = ChangeTracker.Entries<IAuditableEntity>();

  if (changeSet != null)
  {
    foreach (var entry in changeSet.Where(p => p.State != EntityState.Unchanged))
    {
      var now = DateTime.UtcNow;
      if (entry.State == EntityState.Added)
        entry.Entity.CreatedOn = now;
      entry.Entity.LastModified = now;
    }
  }      
  return base.SaveChanges();
}

And now I have a migration in place that seeds some users, like this:

protected override void Seed(MyContext context)
{
  context.Users.AddOrUpdate(
      p => p.UserName,
      new User { Active = true, 
                 FullName = "My user name",
                 UserName = "ThisUser",
                 PasswordHash = "",
                 Email = "my@email",
                 LastLogin = null,
            }
      // etc.
    );
}

Now I have a problem on seeding with AddOrUpdate after the migration. When the entity is new (it's being added), CreatedOn gets filled correctly and everything works as expected. However when the entity is modified (it already exists on the database and UserName matches), it tries to update it with the new entity I'm creating... this fails because CreatedOn has an invalid DateTime (in this case, DateTime.MinValue).

Is there any way to use the AddOrUpdate method so that it actually retrieves the matching entity from the database and just update the non-default fields? Or maybe some way to tell it which fields NOT to update? For this specific case, I'd like the CreatedOn field to be unchanged, but a generic solution would be appreciated.

Maybe I should do my own AddOrUpdate method which includes a predicate with the fields I want to change, instead of passing it a completely new entity?

This is EF 6.1

Update

I know I can easily solve this for the CreatedOn date, this is what I'm currently doing for this specific case:

foreach (var entry in changeSet.Where(c => c.State != EntityState.Unchanged))
{
  var now = DateTime.UtcNow;
  if (entry.State == EntityState.Added)
  {
    entry.Entity.CreatedOn = now;
  }
  else
  {
    if (entry.Property(p => p.CreatedOn).CurrentValue == DateTime.MinValue)
    {
      var original = entry.Property(p => p.CreatedOn).OriginalValue;
      entry.Property(p => p.CreatedOn).CurrentValue = original != SqlDateTime.MinValue ? original : now;
      entry.Property(p => p.CreatedOn).IsModified = true;
     }
   }
   entry.Entity.LastModified = now;
}

I am looking for a more generic solution though

2条回答
看我几分像从前
2楼-- · 2020-03-12 07:29

I ran into an issue with Expression.Equals in @Yuliam's answer where a type was nullable and had to add the following.

var matches = identifyingProperties.Select(pi => 
     Expression.Equal(Expression.Property(parameter, pi.Name), 
     Expression.Convert(Expression.Constant(pi.GetValue(entity, null)), 
     Expression.Property(parameter, pi.Name).Type)));

I also updated this to fetch all records first so "SingleOrDefault" doesn't execute a sql query in each for loop iteration.

I also set AddRange which gets a little better performance.

Here is a gist of my solution. Thanks for posting this Yuliam! I've been looking for something like this for a while.

https://gist.github.com/twilly86/eb6b61a22b66b4b33717aff84a31a060

查看更多
何必那么认真
3楼-- · 2020-03-12 07:33

The implementation of AddOrUpdate uses CurrentValues.SetValues so that all scalar properties will be modified.

I have extended the functionality to accept properties to be modified when it's an update, otherwise it's a creation, just use DbSet<T>::Add.

using System;
using System.Collections.Generic;
using System.Data.Entity;
using System.Data.Entity.Migrations;
using System.Diagnostics;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;

public static class SeedExtension
{
    public static void Upsert<T>(this DbContext db, Expression<Func<T, object>> identifierExpression, Expression<Func<T, object>> updatingExpression, params T[] entities)
        where T : class
    {
        if (updatingExpression == null)
        {
            db.Set<T>().AddOrUpdate(identifierExpression, entities);
            return;
        }

        var identifyingProperties = GetProperties<T>(identifierExpression).ToList();
        Debug.Assert(identifyingProperties.Count != 0);

        var updatingProperties = GetProperties<T>(updatingExpression).Where(pi => IsModifiedable(pi.PropertyType)).ToList();
        Debug.Assert(updatingProperties.Count != 0);

        var parameter = Expression.Parameter(typeof(T));
        foreach (var entity in entities)
        {
            var matches = identifyingProperties.Select(pi => Expression.Equal(Expression.Property(parameter, pi.Name), Expression.Constant(pi.GetValue(entity, null))));
            var matchExpression = matches.Aggregate<BinaryExpression, Expression>(null, (agg, v) => (agg == null) ? v : Expression.AndAlso(agg, v));

            var predicate = Expression.Lambda<Func<T, bool>>(matchExpression, new[] { parameter });
            var existing = db.Set<T>().SingleOrDefault(predicate);
            if (existing == null)
            {
                // New.
                db.Set<T>().Add(entity);
                continue;
            }

            // Update.
            foreach (var prop in updatingProperties)
            {
                var oldValue = prop.GetValue(existing, null);
                var newValue = prop.GetValue(entity, null);
                if (Equals(oldValue, newValue)) continue;

                db.Entry(existing).Property(prop.Name).IsModified = true;
                prop.SetValue(existing, newValue);                    
            }
        }
    }

    private static bool IsModifiedable(Type type)
    {
        return type.IsPrimitive || type.IsValueType || type == typeof(string);
    }

    private static IEnumerable<PropertyInfo> GetProperties<T>(Expression<Func<T, object>> exp) where T : class
    {
        Debug.Assert(exp != null);
        Debug.Assert(exp.Body != null);
        Debug.Assert(exp.Parameters.Count == 1);

        var type = typeof(T);
        var properties = new List<PropertyInfo>();

        if (exp.Body.NodeType == ExpressionType.MemberAccess)
        {
            var memExp = exp.Body as MemberExpression;
            if (memExp != null && memExp.Member != null)
                properties.Add(type.GetProperty(memExp.Member.Name));
        }
        else if (exp.Body.NodeType == ExpressionType.Convert)
        {
            var unaryExp = exp.Body as UnaryExpression;
            if (unaryExp != null)
            {
                var propExp = unaryExp.Operand as MemberExpression;
                if (propExp != null && propExp.Member != null)
                    properties.Add(type.GetProperty(propExp.Member.Name));
            }
        }
        else if (exp.Body.NodeType == ExpressionType.New)
        {
            var newExp = exp.Body as NewExpression;
            if (newExp != null)
                properties.AddRange(newExp.Members.Select(x => type.GetProperty(x.Name)));
        }

        return properties.OfType<PropertyInfo>();
    }
}

Usage.

context.Upsert(
    p => p.UserName,   
    p => new { p.Active, p.FullName, p.Email },
    new User
    {
        Active = true, 
        FullName = "My user name",
        UserName = "ThisUser",
        Email = "my@email",
    }
);
查看更多
登录 后发表回答