Search code examples
nhibernategethashcode

Does this solve Nhibernate identity problem and GetHashCode issues?


The solution I propose involves quite a bit of code, but you can just copy it all and past it in a VS test solution assuming you have SqLite installed, and you should be able to run the tests yourself.

As I have been struggling with the object identity versus object equality and database identity problem using Nhibernate, I have read various posts. However, I could not get a clear picture of how to properly set up object identity in conjunction with collections. Basically, the big problem, as I got, is that once an object is added to a collection it's identity (as derived by the GetHashCode) method cannot change. The preferred way to implement the GetHasHCode is to use a business key. But what if the business key was not the right one? I would like to have that entity updated with it's new business key. But then my collections are out of sync as I violated the immutability of the identity of that object.

The below code is a proposal to solve this problem. However, as I am certainly not a NHibernate expert and also not a very experienced developer, I would gladly receive comments from more senior developers whether this is a viable approach.

using System;
using System.Collections.Generic;
using FluentNHibernate.Cfg;
using FluentNHibernate.Cfg.Db;
using FluentNHibernate.Mapping;
using Iesi.Collections.Generic;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using NHibernate;
using NHibernate.Cfg;
using NHibernate.Tool.hbm2ddl;
using NHibernate.Util;

namespace NHibernateTests
{
    public class InMemoryDatabase : IDisposable
    {
        private static Configuration _configuration;
        private static ISessionFactory _sessionFactory;

        private ISession _session;

        public ISession Session { get { return _session ?? (_session = _sessionFactory.OpenSession()); } }

        public InMemoryDatabase()
        {
// Uncomment this line if you do not use NHProfiler
            HibernatingRhinos.Profiler.Appender.NHibernate.NHibernateProfiler.Initialize();
            _sessionFactory = CreateSessionFactory();
            BuildSchema(Session);
        }

        private static ISessionFactory CreateSessionFactory()
        {
            return Fluently.Configure()
              .Database(SQLiteConfiguration.Standard.InMemory().Raw("hbm2ddl.keywords", "none").ShowSql())
              .Mappings(m => m.FluentMappings.AddFromAssemblyOf<Brand>())
              .ExposeConfiguration(cfg => _configuration = cfg)
              .BuildSessionFactory();
        }

        private static void BuildSchema(ISession Session)
        {
            SchemaExport export = new SchemaExport(_configuration);
            export.Execute(true, true, false, Session.Connection, null);
        }

        public void Dispose()
        {
            Session.Dispose();
        }

    }


    public abstract class Entity<T>
        where T: Entity<T>
    {
        private readonly IEqualityComparer<T> _comparer;

        protected Entity(IEqualityComparer<T> comparer)
        {
            _comparer = comparer;
        } 

        public virtual Guid Id { get; protected set; }

        public virtual bool IsTransient()
        {
            return Id == Guid.Empty;
        }

        public override bool Equals(object obj)
        {
            if (obj == null) return false;
            return _comparer.Equals((T)this, (T)obj);
        }

        public override int GetHashCode()
        {
            return  _comparer.GetHashCode((T)this);
        }

    }

    public class Brand: Entity<Brand>
    {
        protected Brand() : base(new BrandComparer()) {}

        public Brand(String name) : base (new BrandComparer())
        {
            SetName(name);
        }

        private void SetName(string name)
        {
            Name = name;
        }

        public virtual String Name { get; protected set; }

        public virtual Manufactor Manufactor { get; set; }

        public virtual void ChangeName(string name)
        {
            Name = name;
        }
    }

    public class BrandComparer : IEqualityComparer<Brand>
    {
        public bool Equals(Brand x, Brand y)
        {
            return x.Name == y.Name;
        }

        public int GetHashCode(Brand obj)
        {
            return obj.Name.GetHashCode();
        }
    }

    public class BrandMap : ClassMap<Brand>
    {
        public BrandMap()
        {
            Id(x => x.Id).GeneratedBy.GuidComb();
            Map(x => x.Name).Not.Nullable().Unique();
            References(x => x.Manufactor)
                .Cascade.SaveUpdate();
        }
    }

    public class Manufactor : Entity<Manufactor>
    {
        private Iesi.Collections.Generic.ISet<Brand> _brands = new HashedSet<Brand>();

        protected Manufactor() : base(new ManufactorComparer()) {}

        public Manufactor(String name) : base(new ManufactorComparer())
        {
            SetName(name);
        }

        private void SetName(string name)
        {
            Name = name;
        }

        public virtual String Name { get; protected set; }

        public virtual Iesi.Collections.Generic.ISet<Brand> Brands
        {
            get { return _brands; }
            protected set { _brands = value; }
        }

        public virtual void AddBrand(Brand brand)
        {
            if (_brands.Contains(brand)) return;

            _brands.Add(brand);
            brand.Manufactor = this;
        }
    }

    public class ManufactorMap : ClassMap<Manufactor>
    {
        public ManufactorMap()
        {
            Id(x => x.Id);
            Map(x => x.Name);
            HasMany(x => x.Brands)
                .AsSet()
                .Cascade.AllDeleteOrphan().Inverse();
        }
    }

    public class ManufactorComparer : IEqualityComparer<Manufactor>
    {
        public bool Equals(Manufactor x, Manufactor y)
        {
            return x.Name == y.Name;
        }

        public int GetHashCode(Manufactor obj)
        {
            return obj.Name.GetHashCode();
        }
    }

    public static class IdentityChanger
    {
        public static void ChangeIdentity<T>(Action<T> changeIdentity, T newIdentity, ISession session)
        {
            changeIdentity.Invoke(newIdentity);
            session.Flush();
            session.Clear();
        }
    }

    [TestClass]
    public class BusinessIdentityTest
    {
        private InMemoryDatabase _db;

        [TestInitialize]
        public void SetUpInMemoryDb()
        {
            _db = new InMemoryDatabase();
        }

        [TestCleanup]
        public void DisposeInMemoryDb()
        {
            _db.Dispose();
        }

        [TestMethod]
        public void ThatBrandIsIdentifiedByBrandComparer()
        {
            var brand = new Brand("Dynatra");

            Assert.AreEqual("Dynatra".GetHashCode(), new BrandComparer().GetHashCode(brand));
        }

        [TestMethod]
        public void ThatSetOfBrandIsHashedByBrandComparer()
        {
            var brand = new Brand("Dynatra");
            var manufactor = new Manufactor("Lily");
            manufactor.AddBrand(brand);

            Assert.IsTrue(manufactor.Brands.Contains(brand));
        }

        [TestMethod]
        public void ThatHashOfBrandInSetIsThatOfComparer()
        {
            var brand = new Brand("Dynatra");
            var manufactor = new Manufactor("Lily");
            manufactor.AddBrand(brand);

            Assert.AreEqual(manufactor.Brands.First().GetHashCode(), "Dynatra".GetHashCode());
        }

        [TestMethod]
        public void ThatSameBrandCannotBeAddedTwice()
        {
            var brand = new Brand("Dynatra");
            var duplicate = new Brand("Dynatra");
            var manufactor = new Manufactor("Lily");
            manufactor.AddBrand(brand);
            manufactor.AddBrand(duplicate);

            Assert.AreEqual(1, manufactor.Brands.Count);
        }

        [TestMethod]
        public void ThatPersistedBrandIsSameAsLoadedBrandWithSameId()
        {
            var brand = new Brand("Dynatra");
            var manufactor = new Manufactor("Lily");
            manufactor.AddBrand(brand);

            _db.Session.Transaction.Begin();
            _db.Session.Save(brand);

            var copy = _db.Session.Load<Brand>(brand.Id);
            _db.Session.Transaction.Commit();

            Assert.AreSame(brand, copy);
        }

        [TestMethod]
        public void ThatLoadedBrandIsContainedByManufactor()
        {
            var brand = new Brand("Dynatra");
            var manufactor = new Manufactor("Lily");
            manufactor.AddBrand(brand);

            _db.Session.Transaction.Begin();
            _db.Session.Save(brand);

            var copy = _db.Session.Load<Brand>(brand.Id);
            _db.Session.Transaction.Commit();

            Assert.IsTrue(brand.Manufactor.Brands.Contains(copy));
        }

        [TestMethod]
        public void ThatAbrandThatIsLoadedUsesTheSameHash()
        {
            var brand = new Brand("Dynatra");
            var manufactor = new Manufactor("Lily");
            manufactor.AddBrand(brand);

            _db.Session.Transaction.Begin();
            _db.Session.Save(brand);
            var id = brand.Id;

            brand = _db.Session.Load<Brand>(brand.Id);

            Assert.IsTrue(brand.Manufactor.Brands.Contains(new Brand("Dynatra")));

        }

        [TestMethod]
        public void ThatBrandCannotBeFoundIfIdentityChanges()
        {
            var brand = new Brand("Dynatra");
            var manufactor = new Manufactor("Lily");
            manufactor.AddBrand(brand);

            _db.Session.Transaction.Begin();
            _db.Session.Save(brand);

            Assert.IsTrue(brand.Manufactor.Brands.Contains(new Brand("Dynatra")));
            brand.ChangeName("Dynatra_");

            Assert.AreEqual("Dynatra_", brand.Name);
            Assert.AreEqual("Dynatra_".GetHashCode(), brand.Manufactor.Brands.First().GetHashCode());
            Assert.IsFalse(brand.Manufactor.Brands.Contains(brand));
            // ToDo: I don't understand why this test fails
            Assert.IsTrue(brand.Manufactor.Brands.Contains(new Brand("Dynatra")));
        }

        [TestMethod]
        public void ThatSessionNeedsToBeClearedAfterIdentityChange()
        {
            var brand = new Brand("Dynatra");
            var manufactor = new Manufactor("Lily");
            manufactor.AddBrand(brand);

            _db.Session.Transaction.Begin();
            _db.Session.Save(brand);
            var id = brand.Id;

            brand = _db.Session.Load<Brand>(brand.Id);

            // This makes the test pass
            IdentityChanger.ChangeIdentity(brand.ChangeName, "Dynatra_", _db.Session);

            brand = _db.Session.Load<Brand>(id);

            Assert.IsFalse(brand.Manufactor.Brands.Contains(new Brand("Dynatra")));
            Assert.IsTrue(brand.Manufactor.Brands.Contains(new Brand("Dynatra_")));

        }
    }
}

Important edit! I now consider the approach I was suggesting, as has been pointed out as not the right approach. I have provided a different answer to the dilemma I was facing.


Solution

  • I think the basic misconception here is that you implement Equals and GetHashCode based on business data. I don't know why you prefer that, I can't see any advantage in it. Except - of course - when dealing with a value object which doesn't have an Id.

    There is a great post on nhforge.org about Identity Field, Equality and Hash Code

    Edit: This part of your code will cause problems:

        public static class IdentityChanger
        {
            public static void ChangeIdentity<T>(Action<T> changeIdentity, T newIdentity, ISession session)
            {
                changeIdentity.Invoke(newIdentity);
                session.Flush();
                session.Clear();
            }
        }
    
    1. Flushing is expensive
    2. Clearing the session makes NH loading the same entities again.
      1. It may produce much too many db queries because the entities aren't found in the session anymore.
      2. It may produce confusion when an entity that had been read from the db is linked to another and NH complains that it is transient
      3. It may produce memory leaks, for instance when it happens in a loop

    You should implement Equals and GetHashCode based on immutable data. Changing the hash is not possible in a reasonable way.