4

probably this question has been asked in many forms before but still I think their is no clear solution with the scenario.

I have following entity classes.

public class Project
{
    public int ProjectId { get; set; }
    [Required(ErrorMessage="please enter name")]
    public string Name { get; set; }
    public string Url { get; set; }
    public DateTime CreatedOn { get; set; }
    public DateTime UpdatedOn { get; set; }
    public bool isFeatured { get; set; }
    public bool isDisabled { get; set; }
    public int GroupId { get; set; }
    public virtual Group Group { get; set; }
    [Required(ErrorMessage="Please select atleast one tag")]
    public virtual ICollection<Tag> Tags { get; set; }
}

public class Tag
{
    public int TagId { get; set; }
    public string Name { get; set; }
    public DateTime CreatedOn { get; set; }
    public DateTime UpdatedOn { get; set; }
    public virtual ICollection<Project> Projects { get; set; }
}

public class Group
{
    public int GroupId { get; set; }
    public string Name { get; set; }
    public DateTime CreatedOn { get; set; }
    public DateTime UpdatedOn { get; set; }
    public virtual ICollection<Project> Projects { get; set; }
}

I have viewmodel for project entity and a custom model binder for this viewmodel.

public class NewProjectModelBinder : DefaultModelBinder
{
    public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        ProjectNewViewModel model = (ProjectNewViewModel)bindingContext.Model ??
            (ProjectNewViewModel)DependencyResolver.Current.GetService(typeof(ProjectNewViewModel));
        bool hasPrefix = bindingContext.ValueProvider.ContainsPrefix(bindingContext.ModelName);
        string searchPrefix = (hasPrefix) ? bindingContext.ModelName + ".":"";

        //since viewmodel contains custom types like project make sure project is not null and to pass key arround for value providers
        //use Project.Name even if your makrup dont have Project prefix

        model.Project  = model.Project ?? new Project();
        //populate the fields of the model
        if (GetValue(bindingContext, searchPrefix, "Project.ProjectId") !=  null)
        {
            model.Project.ProjectId = int.Parse(GetValue(bindingContext, searchPrefix, "Project.ProjectId"));
        }

        //
        model.Project.Name = GetValue(bindingContext, searchPrefix, "Project.Name");
        model.Project.Url = GetValue(bindingContext, searchPrefix, "Project.Url");
        model.Project.CreatedOn  =  DateTime.Now;
        model.Project.UpdatedOn = DateTime.Now;
        model.Project.isDisabled = GetCheckedValue(bindingContext, searchPrefix, "Project.isDisabled");
        model.Project.isFeatured = GetCheckedValue(bindingContext, searchPrefix, "Project.isFeatured");
        model.Project.GroupId = int.Parse(GetValue(bindingContext, searchPrefix, "Project.GroupId"));
        model.Project.Tags = new List<Tag>();

        foreach (var tagid in GetValue(bindingContext, searchPrefix, "Project.Tags").Split(','))
        {
            var tag = new Tag { TagId = int.Parse(tagid)};
            model.Project.Tags.Add(tag);
        }

        var total = model.Project.Tags.Count;

        return model;
    }

    private string GetValue(ModelBindingContext context, string prefix, string key)
    {
        ValueProviderResult vpr = context.ValueProvider.GetValue(prefix + key);
        return vpr == null ? null : vpr.AttemptedValue;
    }

    private bool GetCheckedValue(ModelBindingContext context, string prefix, string key)
    {
        bool result = false;
        ValueProviderResult vpr = context.ValueProvider.GetValue(prefix + key);
        if (vpr != null)
        {
            result = (bool)vpr.ConvertTo(typeof(bool));
        }

        return result;
    }
}

//My project controller edit action defined as under:
[HttpPost]
[ActionName("Edit")]
public ActionResult EditProject( ProjectNewViewModel ProjectVM)
{
   if (ModelState.IsValid) {
       projectRepository.InsertOrUpdate(ProjectVM.Project);
       projectRepository.Save();
       return RedirectToAction("Index");
   } 
   else {
    ViewBag.PossibleGroups = groupRepository.All;
        return View();
   }
}


//Group Repository
public void InsertOrUpdate(Project project)
    {
        if (project.ProjectId == default(int)) {
            // New entity
            foreach (var tag in project.Tags)
            {
                context.Entry(tag).State = EntityState.Unchanged;
            }
            context.Projects.Add(project);
        } else {               
            context.Entry(project).State = EntityState.Modified;
        }
    }

Now when I have a project inside edit view and i choose new tags for the project and submits the form edit action parameter use model binder and set all the properties of project object including tags. But when the project object is passed to insertorupdate method of grouprepository all the changes that we made get sotred in database except Tags collection property now I am really frustrated with this thing.

Please provide me the solution that would not make changes in the structure have been developed this far.

devuxer
  • 41,681
  • 47
  • 180
  • 292
Najam Awan
  • 1,113
  • 3
  • 13
  • 30

2 Answers2

3

Something like this for your else case in InsertOrUpdate (the if case is fine in my opinion) might work:

//...
else {
    // Reload project with all tags from DB
    var projectInDb = context.Projects.Include(p => p.Tags)
        .Single(p => p.ProjectId == project.ProjectId);

    // Update scalar properties of the project
    context.Entry(projectInDb).CurrentValues.SetValues(project);

    // Check if tags have been removed, if yes: remove from loaded project tags
    foreach(var tagInDb in projectInDb.Tags.ToList())
    {
        // Check if project.Tags collection contains a tag with TagId
        // equal to tagInDb.TagId. "Any" just asks: Is there an element
        // which meets the condition, yes or no? It's like "Exists".
        if (!project.Tags.Any(t => t.TagId == tagInDb.TagId))
            projectInDb.Tags.Remove(tagInDb);
    }

    // Check if tags have been added, if yes: add to loaded project tags
    foreach(var tag in project.Tags)
    {
        // Check if projectInDb.Tags collection contains a tag with TagId
        // equal to tag.TagId. See comment above.
        if (!projectInDb.Tags.Any(t => t.TagId == tag.TagId))
        {
            // We MUST attach because tag already exists in the DB
            // but it was not assigned to the project yet. Attach tells
            // EF: "I know that it exists, don't insert a new one!!!"
            context.Tags.Attach(tag);
            // Now, we just add a new relationship between projectInDb and tag,
            // not a new tag itself
            projectInDb.Tags.Add(tag);
        }
    }
}

// context.SaveChanges() somewhere later

SaveChanges will actually save the formerly reloaded project with the tag list due to EF change detection. The project passed into the method is even not attached to the context and just used to update the reloaded project and its tag list.

Edit

context.Tags.Attach(tag); added to code, otherwise SaveChanges would create new tags in the database.

Slauma
  • 175,098
  • 59
  • 401
  • 420
  • as i paste your code inside my else block and run the code get following error `The conversion of a datetime2 data type to a datetime data type resulted in an out-of-range value. The statement has been terminated.` – Najam Awan Sep 21 '11 at 20:21
  • The conversion of a datetime2 data type to a datetime data type resulted in an out-of-range value. The statement has been terminated. Description: An unhandled exception occurred during the execution of the current web request. Please review the stack trace for more information about the error and where it originated in the code. Exception Details: System.Data.SqlClient.SqlException: The conversion of a datetime2 data type to a datetime data type resulted in an out-of-range value. The statement has been terminated. – Najam Awan Sep 21 '11 at 20:22
  • debug shows projectInDb date fields have values set using datetime.now which were 9/22/2011 1:41:43 AM Projects table date columns are of type datetime. – Najam Awan Sep 21 '11 at 20:45
  • @najam: I don't think that this has to do with the code above. This error happens if you have a column of type `datetime` in SQL Server DB and then try to save an uninitialized `DateTime` of your entity (therefore having `DateTime.MinValue`, year 0001) or some very old year. `DateTime` in .NET ( = `datetime2` in SQL Server) has a much wider range than `datetime` in SQL Server and such an old date cannot be stored in a `datetime` column. Solution: Either change all your DateTime columns in SQL to `datetime2` or make sure that your `DateTime` properties are initialized to some not too old date. – Slauma Sep 21 '11 at 20:49
  • I told you I debug the code project that is parameter have datetime properties their value set to datetime.now whenever you create new project or edit – Najam Awan Sep 21 '11 at 21:10
  • @najam: I see, it was an overlap, I was writing my comment while you posted your last comment. Actually, it could be that the problem are the `DateTime` properties on your `Tag` entity (if those DateTimes are not initialized) because the last foreach loop leads to inserting new tags to the DB since they are not attached to the context. I'm going to change the code. – Slauma Sep 21 '11 at 21:20
  • i think you mean if tags are not attached to the context it will create those tags again in db? – Najam Awan Sep 21 '11 at 21:22
  • @najam: Right! It would submit an INSERT statement for the tags with possibly not initialized `DateTime` properties, which could be the source for your exception. – Slauma Sep 21 '11 at 21:25
  • @najam: I had already written the Edit section in my answer one hour ago, see above. It's just the additional `context.Tags.Attach(tag);` in the code. Did you already test it? – Slauma Sep 21 '11 at 22:37
  • its working God I am so happy can you please comment your code I am little confused how if with any statements filtering tags – Najam Awan Sep 21 '11 at 22:41
  • @najam: I've added a few comments to the code, hopefully it makes things clearer. (`Any` is just a standard LINQ operator.) Cool that is works now :) – Slauma Sep 21 '11 at 22:57
2

I created a helper on the DBContext [CodeFirst]

    /// <summary>
    /// Reattaches the relationships so that they can be committed in a <see cref="DbContext.SaveChanges()"/>
    /// Determines equality using <see cref="OPSDEV.Utils.EF.KeyEqualityComparer"/>
    /// </summary>
    /// <typeparam name="T">The Model or Entity to Attach</typeparam>
    /// <param name="db">The DbContext to use to do the reattaching</param>
    /// <param name="new">The new list of values to attach</param>
    /// <param name="old">The old or previous values that existed in the database</param>
    /// <returns>The new list to be committed</returns>
    public static ICollection<T> AttachToContext<T>(this DbContext db, ICollection<T> @new, ICollection<T> old) where T : class
    {
      if (@new == null) return null;

      var result = new List<T>();

      var comparer = new KeyEqualityComparer<T>();
      var added = @new.Where(c => !old.Contains(c, comparer)).ToList();
      var existing = old.Where(c => @new.Contains(c, comparer)).ToList();

      foreach (var entity in added)
      {
        db.Entry(entity).State = EntityState.Unchanged;
        result.Add(entity);
      }

      foreach (var entity in existing)
      {
        db.Entry(entity).State = EntityState.Unchanged;
        result.Add(entity);
      }

      return result;
    }

It uses a KeyEqualityComparer

  /// <summary>
  /// Uses the Key attribute to determine equality. 
  /// Both keys but have have equal values for the comparer to return true.
  /// Throws "No Key property found" ArgumentException if no key attribute can be found.
  /// </summary>
  /// <typeparam name="T">The Model or Entity type to be compared</typeparam>
  public class KeyEqualityComparer<T> : EqualityComparer<T>
  {
    private PropertyInfo Property { get; set; }
    public KeyEqualityComparer()
    {
      Property = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance)
      .FirstOrDefault(p => p.GetCustomAttributes(typeof(KeyAttribute), false).Any());

      if (Property == null)
        throw new ArgumentException("No Key property found");
    }

    public override bool Equals(T x, T y)
    {
      return GetValue(x).Equals(GetValue(y));
    }

    public override int GetHashCode(T obj)
    {
      return GetValue(obj).GetHashCode();
    }

    public object GetValue(object obj)
    {
      var value = Property.GetValue(obj, null);
      return  value ?? default(T);
    }
  }
Oliver
  • 35,233
  • 12
  • 66
  • 78