nhibernate-core icon indicating copy to clipboard operation
nhibernate-core copied to clipboard

Query cache breaks result transformers which need aliases

Open delta-emil opened this issue 1 month ago • 1 comments

Summary

If you use query caching on a query with a result transformers which use the aliases array (e.g. AliasToBeanResultTransformer), when the data comes from cache, TransformTuple receives an empty aliases array, thus preventing the transformer from functioning correctly.

How to reproduce

This is a test you can add to src/NHibernate.Test/CacheTest/QueryCacheFixture.cs in order to reproduce the problem:

		[Test]
		public void QueryCacheWithAliasToBeanTransformer()
		{
			using (var s = OpenSession())
			{
				const string query = "select s.id_ as Id from Simple as s";
				var result1 = s
					.CreateSQLQuery(query)
					.SetCacheable(true)
					.SetResultTransformer(Transformers.AliasToBean<SimpleDTO>())
					.UniqueResult();

				Assert.That(result1, Is.InstanceOf<SimpleDTO>());
				var dto1 = (SimpleDTO)result1;
				Assert.That(dto1.Id, Is.EqualTo(1));

				// Run a second time, just to test the query cache
				var result2 = s
					.CreateSQLQuery(query)
					.SetCacheable(true)
					.SetResultTransformer(Transformers.AliasToBean<SimpleDTO>())
					.UniqueResult();

				Assert.That(result2, Is.InstanceOf<SimpleDTO>());
				var dto2 = (SimpleDTO)result2;
				Assert.That(dto2.Id, Is.EqualTo(1)); // <-- the Id is 0 because the transformer failed
			}
		}

		private class SimpleDTO
		{
			public long Id { get; set; }
			public string Address { get; set; }
		}

Investigation

What seems to happen is that:

A CustomLoader instance is used. Its GetResultList method relies on ReturnAliasesForTransformer/transformerAliases to get the alias array. transformerAliases could be filled either in the constructor (where in this case it is not filled because customQuery.CustomQueryReturns) and also in AutoDiscoverTypes.

In Loader.ListUsingQueryCache, when the data is not found the cache, DoList is called, which ends up calling AutoDiscoverTypes on the result transformer somewhere inside. However, when the data is found in the cache, nothing calls AutoDiscoverTypes on the result transformer and it remains the empty array created in the constructor.

As AutoDiscoverTypes uses the metadata returned by the database, and when using the cache, we are not doing a database query, the answer is probably to add the aliases to the cached data and read them out from there. And indeed they are stored in the cache and also read out by StandardQueryCache via GetResultsMetadata and then put into the QueryKey's CacheableResultTransformer by calling its SupplyAutoDiscoveredParameters method.

However those aliases never makes it back into the CustomLoader's transformerAliases, which is what the user-code-supplied transformer (in this case, the AliasToBeanResultTransformer) eventually gets.

Additional considerations

I encountered it with CreateSQLQuery/ISQLQuery, and I'm not sure if it happens with other types of queries.

Fix proposal

I created a fix proposal: #3714 I'm hoping to get it fixed in 5.5, because I'm having some trouble with 5.6's serialization change.

delta-emil avatar Oct 29 '25 12:10 delta-emil

Partial workaround

Note that the problem can be worked around if you declare the return aliases explicitly using AddScalar on the ISQLQuery, although it requires you to also specify their type in terms of NHibernate.Type.IType.

Getting the adequat NHibernate.Type.IType could be a bit hard-to-impossible in some cases, which is why I consider this only a partial workaround. Then again, if you want to cache the query, you probably know the return types, so it's likely possible in most realistic scenarios.

So this modified version of the test passes:

		[Test]
		public void QueryCacheWithAliasToBeanTransformer()
		{
			using (var s = OpenSession())
			{
				const string query = "select s.id_ as Id from Simple as s";
				var result1 = s
					.CreateSQLQuery(query)
					.AddScalar("Id", NHibernateUtil.Int64) // <---------------------- workaround
					.SetCacheable(true)
					.SetResultTransformer(Transformers.AliasToBean<SimpleDTO>())
					.UniqueResult();

				Assert.That(result1, Is.InstanceOf<SimpleDTO>());
				var dto1 = (SimpleDTO) result1;
				Assert.That(dto1.Id, Is.EqualTo(1));

				// Run a second time, just to test the query cache
				var result2 = s
					.CreateSQLQuery(query)
					.AddScalar("Id", NHibernateUtil.Int64) // <---------------------- workaround
					.SetCacheable(true)
					.SetResultTransformer(Transformers.AliasToBean<SimpleDTO>())
					.UniqueResult();

				Assert.That(result2, Is.InstanceOf<SimpleDTO>());
				var dto2 = (SimpleDTO) result2;
				Assert.That(dto2.Id, Is.EqualTo(1));
			}
		}

		private class SimpleDTO
		{
			public long Id { get; set; }
		}

delta-emil avatar Oct 29 '25 14:10 delta-emil