Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Query cache + entity with Enum id + child collection => NullReferenceException when loading from second level cache #3643

Open
cwatzl opened this issue Jan 22, 2025 · 1 comment

Comments

@cwatzl
Copy link

cwatzl commented Jan 22, 2025

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 NullReferenceException thrown in TypeHelper.

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
[...]
cwatzl added a commit to cwatzl/nhibernate-core that referenced this issue Jan 22, 2025
cwatzl added a commit to cwatzl/nhibernate-core that referenced this issue Jan 22, 2025
cwatzl added a commit to cwatzl/nhibernate-core that referenced this issue Jan 28, 2025
@cwatzl
Copy link
Author

cwatzl commented Jan 28, 2025

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 PersistenceContext implementation maintains an IDictionary<CollectionKey, IPersistentCollection>
  • The CollectionKey is a tuple composed of (among others) the parent entity id value, which in this case is a member value of my enum EntityId
  • However, when TypeHelper constructs a CollectionKey for 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.IsEqual is used for comparing that part of CollectionKey and will compare an int with an enum value. The way EnumType is implemented, this always will return false.

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 NHiberbernate'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;
                }
        }
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant