views:

953

answers:

2

I'm new to nHibernate, and trying to get my head around the proper way to update detached objects from a web application form POST. (We're using ASP.NET MVC)

The object I'm trying to update contains (among other things) an IList of child objects, mapped something like this:

We have arranged our MVC edit view form so that when it's posted back, our action method is passed am object (incluing the List<> of child items. We round-trip all the entity ID's correctly via the form.

Our naive attempt at the post action method does a session.SaveOrUpdate(parentObject), with the parentObject which has been scraped from view form by the default modelbinder.

This seems to work fine for any of the following scenarios:

  • Creating a new parent object
  • Modifying the parent's properties
  • Adding new child objects
  • Modifying existing child objects (Looking at nHibernate logs, I can see it correctly establishing if the objects are new or existing, and issuing the appropriate UPDATE or INSERT)

The scenario which fails is: - Deleting child objects - i.e if they're not in the IList, they don't get deleted from the database. There's no exception or anything, they just don't get deleted.

My understanding is that this is because the magic which nHibernate performs to create a list of children which require deletion doesn't work with detached instances.

I have not been able to find a simple example of what this sort of action method should look like with nHibernate (i.e. using a model-binder object as a detached nHibernate instance) - examples based on MS EF (e.g. http://stephenwalther.com/blog/archive/2009/02/27/chapter-5-understanding-models.aspx) seem to use a method 'ApplyPropertyChanges' to copy changed properties from the model-bound object to a re-loaded entity instance.

So, after all that, the question is pretty simple - if I have the model binder give me a new object which contains collections of child objects, how should I update that via nHibernate, (where 'update' includes possibly deletion of children)?

+4  A: 

Here's an example that does what I think you're trying to do. Let me know if I've misunderstood what you're trying to do.

Given the following "domain" classes:

public class Person
{
    private IList<Pet> pets;

    protected Person()
    { }

    public Person(string name)
    {
        Name = name;
        pets = new List<Pet>();
    }

    public virtual Guid Id { get; set; }
    public virtual string Name { get; set; }
    public virtual IEnumerable<Pet> Pets
    {
        get { return pets; }
    }

    public virtual void AddPet(Pet pet)
    {
        pets.Add(pet);
    }

    public virtual void RemovePet(Pet pet)
    {
        pets.Remove(pet);
    }
}

public class Pet
{
    protected Pet()
    { }

    public Pet(string name)
    {
        Name = name;
    }

    public virtual Guid Id { get; set; }
    public virtual string Name { get; set; }
}

With the following mapping:

   public class PersonMap : ClassMap<Person>
    {
        public PersonMap()
        {
            LazyLoad();
            Id(x => x.Id).GeneratedBy.GuidComb();
            Map(x => x.Name);
            HasMany(x => x.Pets)
                   .Cascade.AllDeleteOrphan()
                   .Access.AsLowerCaseField()
                   .SetAttribute("lazy", "false");
        }
    }

    public class PetMap : ClassMap<Pet>
    {
        public PetMap()
        {
            Id(x => x.Id).GeneratedBy.GuidComb();
            Map(x => x.Name);
        }
    }

This test:

    [Test]
    public void CanDeleteChildren()
    {
        Person person = new Person("joe");

        Pet dog = new Pet("dog");
        Pet cat = new Pet("cat");

        person.AddPet(dog);
        person.AddPet(cat);

        Repository.Save(person);

        UnitOfWork.Commit();

        CreateSession();
        UnitOfWork.BeginTransaction();

        Person retrievedPerson = Repository.Get<Person>(person.Id);
        Repository.Evict(retrievedPerson);

        retrievedPerson.Name = "Evicted";

        Assert.AreEqual(2, retrievedPerson.Pets.Count());
        retrievedPerson.RemovePet(retrievedPerson.Pets.First());

        Assert.AreEqual(1, retrievedPerson.Pets.Count());

        Repository.Save(retrievedPerson);

        UnitOfWork.Commit();

        CreateSession();
        UnitOfWork.BeginTransaction();

        retrievedPerson = Repository.Get<Person>(person.Id);
        Assert.AreEqual(1, retrievedPerson.Pets.Count());
    }

runs and generates the following sql:

DeletingChildrenOfEvictedObject.CanDeleteChildren : Passed NHibernate: INSERT INTO [Person] (Name, Id) VALUES (@p0, @p1); @p0 = 'joe', @p1 = 'cd123fc8-6163-42a5-aeeb-9bf801013ab2'

NHibernate: INSERT INTO [Pet] (Name, Id) VALUES (@p0, @p1); @p0 = 'dog', @p1 = '464e59c7-74d0-4317-9c22-9bf801013abb'

NHibernate: INSERT INTO [Pet] (Name, Id) VALUES (@p0, @p1); @p0 = 'cat', @p1 = '010c2fd9-59c4-4e66-94fb-9bf801013abb'

NHibernate: UPDATE [Pet] SET Person_id = @p0 WHERE Id = @p1; @p0 = 'cd123fc8-6163-42a5-aeeb-9bf801013ab2', @p1 = '464e59c7-74d0-4317-9c22-9bf801013abb'

NHibernate: UPDATE [Pet] SET Person_id = @p0 WHERE Id = @p1; @p0 = 'cd123fc8-6163-42a5-aeeb-9bf801013ab2', @p1 = '010c2fd9-59c4-4e66-94fb-9bf801013abb'

NHibernate: SELECT person0_.Id as Id5_0_, person0_.Name as Name5_0_ FROM [Person] person0_ WHERE person0_.Id=@p0; @p0 = 'cd123fc8-6163-42a5-aeeb-9bf801013ab2'

NHibernate: SELECT pets0_.Person_id as Person3_1_, pets0_.Id as Id1_, pets0_.Id as Id6_0_, pets0_.Name as Name6_0_ FROM [Pet] pets0_ WHERE pets0_.Person_id=@p0; @p0 = 'cd123fc8-6163-42a5-aeeb-9bf801013ab2'

NHibernate: UPDATE [Person] SET Name = @p0 WHERE Id = @p1; @p0 = 'Evicted', @p1 = 'cd123fc8-6163-42a5-aeeb-9bf801013ab2'

NHibernate: UPDATE [Pet] SET Name = @p0 WHERE Id = @p1; @p0 = 'dog', @p1 = '464e59c7-74d0-4317-9c22-9bf801013abb' NHibernate: UPDATE [Pet] SET Person_id = null WHERE Person_id = @p0 AND Id = @p1; @p0 = 'cd123fc8-6163-42a5-aeeb-9bf801013ab2', @p1 = '010c2fd9-59c4-4e66-94fb-9bf801013abb'

NHibernate: DELETE FROM [Pet] WHERE Id = @p0; @p0 = '010c2fd9-59c4-4e66-94fb-9bf801013abb'

NHibernate: SELECT person0_.Id as Id5_0_, person0_.Name as Name5_0_ FROM [Person] person0_ WHERE person0_.Id=@p0; @p0 = 'cd123fc8-6163-42a5-aeeb-9bf801013ab2'

NHibernate: SELECT pets0_.Person_id as Person3_1_, pets0_.Id as Id1_, pets0_.Id as Id6_0_, pets0_.Name as Name6_0_ FROM [Pet] pets0_ WHERE pets0_.Person_id=@p0; @p0 = 'cd123fc8-6163-42a5-aeeb-9bf801013ab2'

Note the DELETE FROM [Pet]...

so, what you need to be able to do is hand nhibernate a Person object (in this example) with the modified collections and it should be able to determmine what to delete. Make sure you have the Cascade.AllDeleteOrphan() attribute set.

Rob Scott
Thanks very much for this - a lot of work there! Unfortunately, the bit I'm struggling with is that the 'Person' object I have in the second session/transaction (i.e. my POST) is an entirely new object which was created by the ModelBinder, rather than a retrieved object which has had a few fields modified and had a few 'delete child' calls made on it. I think what I'm looking for is a way to take that new object and apply its changes to the retrieved object, so that nh can then work out the required SQL. Maybe that just doesn't exist.
Will Dean
The way I tend to deal with that situation is by treating the new object created by the model binder as a Presentation Model object. You will still need to retrieve the object (or somehow create an instance of the "persisted" class) you want to update and apply those updates to that object. You can then save that object to NHibernate. Does that make sense?
Rob Scott
So you would 'manually' (i.e. property-by-property, with loops or whatever for the child collections), overwrite the properties of the 'retrieved' object with the properties of the 'presentation (i.e. POST)' object? And manually calculate the required child deletes? Seems a bit more work (and maintenance!) than I was hoping for. Hardly seems worth bothering with the model-binder, really, as if I've got to handle the fields one at a time I might as well pull them straight from the form response. Thanks for the help, though.
Will Dean
A: 

Rob's answer convinced me to look more closely at the 'load the existing item into the new session and then merge' approach, and of course there's ISession.Merge, which appears to do exactly what I wanted, which is to take a fresh object and merge it with it's predecessor who's just been reloaded into the second session.

So I think the answer to the question I tried to ask is "reload the existing entity and then call 'ISession.Merge' with the new entity."

Will Dean