2

This solution involves three entities, Client, Competency and WeaponType.

  • A Client instance can have zero-or-more Competency instances within a List<Competency> member.
  • A Competency instance can have one-or-more WeaponType instances within a List<WeaponType> member. (WeaponType is our lookup member)

Before updating the DbContext, a new List object is assigned to the client. This represents the full updated list of competencies for the client, where old competencies might have been removed and new ones created.

The problem experienced is that dbContext.SaveChanges() causes duplicate WeaponType entries to be created.

Here is the code for my entities:

public class Client : Person
    {
        public ICollection<CompetencyCertificate> CompetencyCertificates
        {
            get;
            set;
        }

    }

public class CompetencyCertificate
    {


        public Int64 Id { get; set; }

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

        [Required]
        public List<WeaponType> CompetencyTypes { get; set; }    

    }



 public class WeaponType
    {
        public Int16 Id { get; set; }

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

    }

And herewith the code for saving my updated client and competency info (which reflects my attempts to overcome this problem as well:

 private void SaveClientProfile()
        {
            HttpRequestBase rb = this.Request;

            string sId = "";
            if (rb.Form["Id"] != null)
                sId = rb.Form["Id"];
            Int64 int64_id = 0;
            if (sId.Trim().Length > 0)
                int64_id = Int64.Parse(sId);
            Client client = loadOrCreateClient(int64_id);

            //Set the newly submitted form data for the client

            client.IDSocialSecurityPassNum = rb.Form["IDNumber"];
            client.EmailAddress = rb.Form["EmailAddress"];
            client.NickName = rb.Form["Name"];
            client.Surname = rb.Form["Surname"];

            //MAP AND TRANSLATE JSON COLLECTION OBJECTS TO ENTITY COLLECTIONS, UPDATE THE CONTEXT    

            Mapper.CreateMap<Client_Competency_ViewModel, CompetencyCertificate>();
            client.CompetencyCertificates = Mapper.Map<List<CompetencyCertificate>>(System.Web.Helpers.Json.Decode<System.Collections.Generic.List<Client_Competency_ViewModel>>(rb.Form["CompetencyCollection"]));    

            //PREVENT EF FROM DUPLICATING LOOKUP VALUES
            AttachLookup<WeaponType>(JCGunsDb.WeaponTypes.ToList<WeaponType>());


            //FNIALISE AND SAVE
            dbContext.UserId = User.Identity.GetUserName();
            dbContext.SaveChanges();
        }



private void AttachLookup<T>(ICollection<T> itemsToAttach) where T : class
        {
            foreach(T item in itemsToAttach)
            {
                JCGunsDb.Entry(item).State = EntityState.Unchanged;
            }
        }

I can confirm the JSON parsing and mapping in the above code works as expected - the Id's for existing entities are in tact and new entity Id's are set to 0.

What am I doing that is causing this behaviour? How do I fix it?


UPDATE: As recommended by Gert, I have tried to implement a solution utilising GraphDiff (which seems to be an exact fit for my requirements). However, I am struggling to get it to work. Here is what I have done (as per Github issue raised):

I have the following:

Client Client >> List CompetencyCertificates CompetencyCertificate >> List CompetencyTypes

I load a client object from the database, and then assign new List values to the List members mentioned above.

Subsequently, I call the following code:

dbContext.UpdateGraph(client, map => map
                .OwnedCollection(cc => cc.CompetencyCertificates, with => with
                    .AssociatedCollection(kt => kt.CompetencyTypes))
                );

dbContext.SaveChanges();

Here is the stacktrace for the exception that gets thrown on the UpdateGraph invocation:

Member 'CurrentValues' cannot be called for the entity of type 'CompetencyCertificate' because the entity does not exist in the context. To add an entity to the context call the Add or Attach method of DbSet.

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.InvalidOperationException: Member 'CurrentValues' cannot be called for the entity of type 'CompetencyCertificate' because the entity does not exist in the context. To add an entity to the context call the Add or Attach method of DbSet.

Source Error:

Line 138: Line 139: //UPDATE GRAPH OF DETACHED ENTITIES Line 140: dbContext.UpdateGraph(client, map => map Line 141: .OwnedCollection(cc => cc.CompetencyCertificates, with => with Line 142: .AssociatedCollection(kt => kt.CompetencyTypes))

Source File: [Not Important] Line: 140

Stack Trace:

[InvalidOperationException: Member 'CurrentValues' cannot be called for the entity of type 'CompetencyCertificate' because the entity does not exist in the context. To add an entity to the context call the Add or Attach method of DbSet.]
System.Data.Entity.Internal.InternalEntityEntry.ValidateNotDetachedAndInitializeRelatedEnd(String method) +102
System.Data.Entity.Internal.InternalEntityEntry.ValidateStateToGetValues(String method, EntityState invalidState) +55
System.Data.Entity.Internal.InternalEntityEntry.get_CurrentValues() +53 System.Data.Entity.Infrastructure.DbEntityEntry.get_CurrentValues() +44 RefactorThis.GraphDiff.DbContextExtensions.RecursiveGraphUpdate(DbContext context, Object dataStoreEntity, Object updatingEntity, UpdateMember member) +942
RefactorThis.GraphDiff.DbContextExtensions.UpdateGraph(DbContext context, T entity, Expression1 mapping) +631
JCGunsOnline.Controllers.ClientController.SaveClientProfile() in c:\Users\Ben\Dropbox\Mighty IT\Active Projects\JCGunsOnline\JCGunsOnline\Views\Client\ClientController.cs:140 JCGunsOnline.Controllers.ClientController.SubmitStep1() in c:\Users\Ben\Dropbox\Mighty IT\Active Projects\JCGunsOnline\JCGunsOnline\Views\Client\ClientController.cs:60 lambda_method(Closure , ControllerBase , Object[] ) +101
System.Web.Mvc.ActionMethodDispatcher.Execute(ControllerBase controller, Object[] parameters) +59
System.Web.Mvc.ReflectedActionDescriptor.Execute(ControllerContext controllerContext, IDictionary
2 parameters) +435
System.Web.Mvc.ControllerActionInvoker.InvokeActionMethod(ControllerContext controllerContext, ActionDescriptor actionDescriptor, IDictionary2 parameters) +60
System.Web.Mvc.Async.ActionInvocation.InvokeSynchronousActionMethod() +76 System.Web.Mvc.Async.AsyncControllerActionInvoker.<BeginInvokeSynchronousActionMethod>b__39(IAsyncResult asyncResult, ActionInvocation innerInvokeState) +36
System.Web.Mvc.Async.WrappedAsyncResult
2.CallEndDelegate(IAsyncResult asyncResult) +73
System.Web.Mvc.Async.WrappedAsyncResultBase1.End() +136
System.Web.Mvc.Async.AsyncResultWrapper.End(IAsyncResult asyncResult, Object tag) +102
System.Web.Mvc.Async.AsyncControllerActionInvoker.EndInvokeActionMethod(IAsyncResult asyncResult) +49
System.Web.Mvc.Async.AsyncInvocationWithFilters.<InvokeActionMethodFilterAsynchronouslyRecursive>b__3f() +117 System.Web.Mvc.Async.<>c__DisplayClass48.<InvokeActionMethodFilterAsynchronouslyRecursive>b__41() +323 System.Web.Mvc.Async.<>c__DisplayClass33.<BeginInvokeActionMethodWithFilters>b__32(IAsyncResult asyncResult) +44
System.Web.Mvc.Async.WrappedAsyncResult
1.CallEndDelegate(IAsyncResult asyncResult) +47
System.Web.Mvc.Async.WrappedAsyncResultBase1.End() +136
System.Web.Mvc.Async.AsyncResultWrapper.End(IAsyncResult asyncResult, Object tag) +102
System.Web.Mvc.Async.AsyncControllerActionInvoker.EndInvokeActionMethodWithFilters(IAsyncResult asyncResult) +50
System.Web.Mvc.Async.<>c__DisplayClass2b.<BeginInvokeAction>b__1c() +72 System.Web.Mvc.Async.<>c__DisplayClass21.<BeginInvokeAction>b__1e(IAsyncResult asyncResult) +185
System.Web.Mvc.Async.WrappedAsyncResult
1.CallEndDelegate(IAsyncResult asyncResult) +42
System.Web.Mvc.Async.WrappedAsyncResultBase1.End() +133
System.Web.Mvc.Async.AsyncResultWrapper.End(IAsyncResult asyncResult, Object tag) +56
System.Web.Mvc.Async.AsyncControllerActionInvoker.EndInvokeAction(IAsyncResult asyncResult) +40
System.Web.Mvc.Controller.<BeginExecuteCore>b__1d(IAsyncResult asyncResult, ExecuteCoreState innerState) +34
System.Web.Mvc.Async.WrappedAsyncVoid
1.CallEndDelegate(IAsyncResult asyncResult) +70
System.Web.Mvc.Async.WrappedAsyncResultBase1.End() +139
System.Web.Mvc.Async.AsyncResultWrapper.End(IAsyncResult asyncResult, Object tag) +59
System.Web.Mvc.Async.AsyncResultWrapper.End(IAsyncResult asyncResult, Object tag) +40
System.Web.Mvc.Controller.EndExecuteCore(IAsyncResult asyncResult) +44 System.Web.Mvc.Controller.<BeginExecute>b__15(IAsyncResult asyncResult, Controller controller) +39
System.Web.Mvc.Async.WrappedAsyncVoid
1.CallEndDelegate(IAsyncResult asyncResult) +62
System.Web.Mvc.Async.WrappedAsyncResultBase1.End() +139
System.Web.Mvc.Async.AsyncResultWrapper.End(IAsyncResult asyncResult, Object tag) +59
System.Web.Mvc.Async.AsyncResultWrapper.End(IAsyncResult asyncResult, Object tag) +40 System.Web.Mvc.Controller.EndExecute(IAsyncResult asyncResult) +39
System.Web.Mvc.Controller.System.Web.Mvc.Async.IAsyncController.EndExecute(IAsyncResult asyncResult) +39
System.Web.Mvc.MvcHandler.<BeginProcessRequest>b__5(IAsyncResult asyncResult, ProcessRequestState innerState) +39
System.Web.Mvc.Async.WrappedAsyncVoid
1.CallEndDelegate(IAsyncResult asyncResult) +70
System.Web.Mvc.Async.WrappedAsyncResultBase`1.End() +139
System.Web.Mvc.Async.AsyncResultWrapper.End(IAsyncResult asyncResult, Object tag) +59
System.Web.Mvc.Async.AsyncResultWrapper.End(IAsyncResult asyncResult, Object tag) +40
System.Web.Mvc.MvcHandler.EndProcessRequest(IAsyncResult asyncResult) +40 System.Web.Mvc.MvcHandler.System.Web.IHttpAsyncHandler.EndProcessRequest(IAsyncResult result) +38
System.Web.CallHandlerExecutionStep.System.Web.HttpApplication.IExecutionStep.Execute() +9514928 System.Web.HttpApplication.ExecuteStep(IExecutionStep step, Boolean& completedSynchronously) +155

2 Answers2

1

So I managed to solve the problem and as such am summarising it here for future reference.

From the code listed in my original question, I was de-serialising from JSON to Enities, thereby basically creating a disconnected graph (because the graph was not loaded from the database and therefore no tracking occurred on the entities.

Entity Framework 6 (and earlier) does not support working with disconnected graphs. (See https://entityframework.codeplex.com/workitem/864)

As @Gert Arnold mentioned above, there is a plug-in component called GraphDiff which does support it. (You can download it from https://github.com/refactorthis/GraphDiff).

I strongly suggest that you build the code from source and do not make use of the Nuget package, as it was out-of-date when I used it and subsequently ran in a battery of bugs which were already fixed in the latest version.

Lastly, keep in mind that GraphDiff does not yet support working with connected graphs / tracked entities, therefore you have to call the .AsNoTracking() method when loading the data for your disconnected graph.

0

The problem is in the line

client.CompetencyCertificates = Mapper.Map<....

All CompetencyCertificates in the collection start as unattached objects when they are deserialized. When you assign the deserialized collection to CompetencyCertificates, all CompetencyCertificate objects change from Detached to Added.

This state change is one that causes all Detached objects in an object graph to be marked as Added as well. So at this point, all WeaponTypes are Added and will be saved as new objects if you don't do anything about it.

If you know for sure that all WeaponType objects will always be existing objects, I think the quickest fix would be to loop through all new CompetencyCertificate objects and mark their WeaponTypes as Unchanged.

This is probably what you're trying to do in AttachLookup, but it seems to me that an entirely different context is involved there, so dbContext's change tracker never gets involved in this.

Gert Arnold
  • 105,341
  • 31
  • 202
  • 291
  • That follows my line of reasoning. This is not what one would expect from an "intelligent" convention based ORM, however. An "intelligent" ORM would detect that some of the objects being assigned are existing, based on the fact that their PK's correspond to those of existing objects, and will just concordantly update said records in the database. New objects have a primary key of zero and of course removed objects will be absent in the list. So I am particulary looking for a solution that would help me avoid having to iterate over each collection to manually set the state of each entry. –  Mar 02 '14 at 12:03
  • If what you described is in fact the "intended" behaviour of EF, I will in future steer my company clear of EF, as it is taking us longer to implement a technology which has actually made data access more difficult by adding an additional layer of complexity. –  Mar 02 '14 at 12:07
  • This is turning out to be quite cumbersome. I tried to write a method to compare the list property on the entity to the newly returned list and to either add, update or remove the individual items from the entity list. Turns out you can't pass the property value by reference and as such you can not abstract this to a method. –  Mar 02 '14 at 13:37
  • 1
    I'm just the messenger :) All I can say is EF is developing. Each version makes it better. Its LINQ support is unsurpassed, that's what appeals to me. For the rest I try to live with its spiky edges. The thing that annoys you apparently has not yet been on the team's priority list. There is a library that fills the gap: GraphDiff. It's not unlikely that in the future something like it will be part of EF. – Gert Arnold Mar 02 '14 at 15:08
  • Haha, thanks Gert. Apologies if I came over too strong... it's not that you put a bad answer in... it's just that I am fed up with this issue to no end :/ I will check out Gaphdiff and get back. I can't believe that I am a fringe case... the application I am writing is barely a step beyond "Hello World" and I am running into issues like these. –  Mar 02 '14 at 15:14
  • 1
    That's alright :) I didn't use GraphDiff myself yet, but read some positive user experiences about it. Hope it takes you further. [Here](http://stackoverflow.com/a/21696096/861716)'s one happy user. – Gert Arnold Mar 02 '14 at 15:18
  • Gert, although you didn't give me the answer, you put me on the right path with your graphdiff comment. 50 reps! Enjoy! –  Mar 06 '14 at 22:43