Query cache + entity with Enum id + child collection => NullReferenceException when loading from second level cache
After upgrading an old legacy project from NH 5.2.7 to 5.5.2 I encountered a peculiar regression bug:
- Root Entity has an Id property having an enum type, and a one-to-many relationship with another entity
- Second level cache and query cache is used for querying the Entity; child collection is fetched eagerly in the query in question
- First execution of the query (with unpopulated cache) works; subsequent executions fail with
NullReferenceExceptionthrown inTypeHelper.
Example model:
class Entity
{
private readonly ICollection<ChildEntity> _children = new List<ChildEntity>();
public virtual EntityId Id { get; protected set; }
public virtual IEnumerable<ChildEntity> Children => _children.AsEnumerable();
}
class ChildEntity
{
public virtual int Id { get; protected set; }
}
enum EntityId
{
Id1,
Id2
}
Mapping:
mapper.Class<Entity>(
rc =>
{
rc.Id(x => x.Id);
rc.Bag(
x => x.Children,
m =>
{
m.Access(Accessor.Field);
m.Key(k => k.Column("EntityId"));
},
r => r.OneToMany());
rc.Cache(
cm =>
{
cm.Include(CacheInclude.All);
cm.Usage(CacheUsage.ReadWrite);
});
});
mapper.Class<ChildEntity>(
rc =>
{
rc.Id(x => x.Id);
rc.Cache(
cm =>
{
cm.Include(CacheInclude.All);
cm.Usage(CacheUsage.ReadWrite);
});
});
Query:
session
.Query<Entity>()
.FetchMany(x => x.Children)
.WithOptions(opt => opt.SetCacheable(true))
.ToList();
Exception thrown on second and all subsequent query executions:
NHibernate.Exceptions.GenericADOException : Could not execute query[SQL: SQL not available]
----> System.NullReferenceException : Object reference not set to an instance of an object.
at NHibernate.Impl.SessionImpl.List(IQueryExpression queryExpression, QueryParameters queryParameters, IList results, Object filterConnection) in C:\dev\nhibernate-core\src\NHibernate\Impl\SessionImpl.cs:line 563
at NHibernate.Impl.SessionImpl.List(IQueryExpression queryExpression, QueryParameters queryParameters, IList results) in C:\dev\nhibernate-core\src\NHibernate\Impl\SessionImpl.cs:line 523
at NHibernate.Impl.AbstractSessionImpl.List[T](IQueryExpression query, QueryParameters parameters) in C:\dev\nhibernate-core\src\NHibernate\Impl\AbstractSessionImpl.cs:line 182
at NHibernate.Impl.AbstractQueryImpl2.List[T]() in C:\dev\nhibernate-core\src\NHibernate\Impl\AbstractQueryImpl2.cs:line 111
at NHibernate.Linq.DefaultQueryProvider.ExecuteList[TResult](Expression expression) in C:\dev\nhibernate-core\src\NHibernate\Linq\DefaultQueryProvider.cs:line 111
at NHibernate.Linq.NhQueryable`1.System.Collections.Generic.IEnumerable<T>.GetEnumerator() in C:\dev\nhibernate-core\src\NHibernate\Linq\NhQueryable.cs:line 65
at System.Collections.Generic.List`1..ctor(IEnumerable`1 collection)
at System.Linq.Enumerable.ToList[TSource](IEnumerable`1 source)
at NHibernate.Test.NHSpecificTest.GHXXXX.FixtureByCode.LoadEntityByNameWithQueryCache() in C:\dev\nhibernate-core\src\NHibernate.Test\NHSpecificTest\GHXXXX\FixtureByCode.cs:line 108
at NHibernate.Test.NHSpecificTest.GHXXXX.FixtureByCode.LoadsEntityWithEnumIdAndChildrenUsingQueryCache() in C:\dev\nhibernate-core\src\NHibernate.Test\NHSpecificTest\GHXXXX\FixtureByCode.cs:line 95
--NullReferenceException
at NHibernate.Type.TypeHelper.InitializeCollections(Object[] cacheRow, Object[] assembleRow, IDictionary`2 collectionIndexes, ISessionImplementor session) in C:\dev\nhibernate-core\src\NHibernate\Type\TypeHelper.cs:line 137
at NHibernate.Cache.StandardQueryCache.InitializeCollections(ICacheAssembler[] returnTypes, ISessionImplementor session, IList assembleResult, IList cacheResult) in C:\dev\nhibernate-core\src\NHibernate\Cache\StandardQueryCache.cs:line 554
at NHibernate.Cache.StandardQueryCache.GetResultFromCacheable(QueryKey key, ICacheAssembler[] returnTypes, Boolean isNaturalKeyLookup, ISessionImplementor session, IList cacheable) in C:\dev\nhibernate-core\src\NHibernate\Cache\StandardQueryCache.cs:line 582
at NHibernate.Cache.StandardQueryCache.Get(QueryKey key, QueryParameters queryParameters, ICacheAssembler[] returnTypes, ISet`1 spaces, ISessionImplementor session) in C:\dev\nhibernate-core\src\NHibernate\Cache\StandardQueryCache.cs:line 147
at NHibernate.Cache.QueryCacheExtensions.Get(IQueryCache queryCache, QueryKey key, QueryParameters queryParameters, ICacheAssembler[] returnTypes, ISet`1 spaces, ISessionImplementor session) in C:\dev\nhibernate-core\src\NHibernate\Cache\IQueryCache.cs:line 147
at NHibernate.Loader.Loader.GetResultFromQueryCache(ISessionImplementor session, QueryParameters queryParameters, ISet`1 querySpaces, IQueryCache queryCache, QueryKey key) in C:\dev\nhibernate-core\src\NHibernate\Loader\Loader.cs:line 1943
at NHibernate.Loader.Loader.ListUsingQueryCache(ISessionImplementor session, QueryParameters queryParameters, ISet`1 querySpaces) in C:\dev\nhibernate-core\src\NHibernate\Loader\Loader.cs:line 1887
at NHibernate.Loader.Loader.List(ISessionImplementor session, QueryParameters queryParameters, ISet`1 querySpaces) in C:\dev\nhibernate-core\src\NHibernate\Loader\Loader.cs:line 1842
at NHibernate.Loader.Hql.QueryLoader.List(ISessionImplementor session, QueryParameters queryParameters) in C:\dev\nhibernate-core\src\NHibernate\Loader\Hql\QueryLoader.cs:line 302
at NHibernate.Hql.Ast.ANTLR.QueryTranslatorImpl.List(ISessionImplementor session, QueryParameters queryParameters) in C:\dev\nhibernate-core\src\NHibernate\Hql\Ast\ANTLR\QueryTranslatorImpl.cs:line 156
at NHibernate.Engine.Query.HQLQueryPlan.PerformList(QueryParameters queryParameters, ISessionImplementor session, IList results) in C:\dev\nhibernate-core\src\NHibernate\Engine\Query\HQLQueryPlan.cs:line 115
at NHibernate.Impl.SessionImpl.List(IQueryExpression queryExpression, QueryParameters queryParameters, IList results, Object filterConnection) in C:\dev\nhibernate-core\src\NHibernate\Impl\SessionImpl.cs:line 553
[...]
I did some more digging and somewhat understand the problem now.
In NHibernate.Type.TypeHelper, this line is supposed to resolve the child collection, but fails:
var collection = session.PersistenceContext.GetCollection(new CollectionKey(pair.Value, value));
The reason for that, as far as I understand it:
- The
PersistenceContextimplementation maintains anIDictionary<CollectionKey, IPersistentCollection> - The
CollectionKeyis a tuple composed of (among others) the parent entity id value, which in this case is a member value of myenum EntityId - However, when
TypeHelperconstructs aCollectionKeyfor lookup, it uses the numeric value (int) of the enum member, instead of the actual enum value. This is because at this point it received this value in disassembled form, i.e. in the shape in which it was stored in the cache. - Therefore, this lookup fails to resolve the collection, because
EnumType.IsEqualis used for comparing that part ofCollectionKeyand will compare anintwith an enum value. The wayEnumTypeis implemented, this always will returnfalse.
Now I don't really know how to solve this properly, as there are many moving parts involved, and I lack in-depth knowledge of NHibernate's internals.
I do have an application-side workaround that solves the problem for now in my legacy project, which is to use a custom subclass of EnumType for mapping my primary key property, like so:
mapper.Class<Entity>(
rc =>
{
rc.Id(x => x.Id, x => x.Type<CacheSafeEnumIdType<EntityId>>());
// ... child collection mapping & caching configuration skipped for brevity, no changes to original example ...
});
/// <summary>
/// Workaround for weird NHibernate bug when using an Enum as primary key in combination with 2nd level cache / query cache.
/// See https://github.com/nhibernate/nhibernate-core/issues/3643
/// If that bug gets fixed, this custom type and its usage can be removed.
/// </summary>
class CacheSafeEnumIdType<TEnum> : EnumType<TEnum> where TEnum : Enum
{
public override bool IsEqual(object x, object y)
{
if (ReferenceEquals(x, y) || base.IsEqual(x, y))
{
return true;
}
// Here comes the actual workaround:
// One of the values might be of an integer type, which may be the case
// when it NHibernate is constructing a cache lookup key from a "disassembled" enum value (enums are stored as int in L2C).
if (TryConvertToEnum(x, out var xEnum) && TryConvertToEnum(y, out var yEnum))
{
return xEnum.Equals(yEnum);
}
return false;
}
private static bool TryConvertToEnum(object obj, out TEnum result)
{
switch (obj)
{
case null:
result = default;
return false;
case TEnum @enum:
result = @enum;
return true;
default:
try
{
result = (TEnum)Enum.ToObject(typeof(TEnum), obj);
return true;
}
catch (ArgumentException)
{
result = default;
return false;
}
}
}
}
Prior to #1952 merged in v5.3, caching fetched relations was not supported, they were not cached. With it, they are cached, but the case of a owner key needing to be assembled was overlooked, which causes the bug.