From d303c5943fe739d9438536b92e121aa5e1e3f66a Mon Sep 17 00:00:00 2001 From: Jeff Hagen Date: Mon, 1 Dec 2025 18:01:56 -0500 Subject: [PATCH 1/5] Fix unfetched lazy property issue #3731 --- src/NHibernate.Test/CacheTest/CacheEntity.cs | 11 ++++ .../CacheTest/JsonSerializerCacheFixture.cs | 50 +++++++++++-------- .../CacheTest/NonStrictReadWrite.cs | 7 +++ .../CacheTest/NonStrictReadWrite.hbm.xml | 15 ++++++ src/NHibernate.Test/CacheTest/ReadOnly.cs | 15 +----- src/NHibernate.Test/CacheTest/ReadWrite.cs | 10 +--- src/NHibernate.Test/NHibernate.Test.csproj | 1 + src/NHibernate.TestDatabaseSetup/App.config | 2 +- src/NHibernate/Engine/IPersistenceContext.cs | 4 +- .../Intercept/ILazyPropertyInitializer.cs | 10 +++- .../Entity/AbstractEntityPersister.cs | 4 +- .../BytecodeEnhancementMetadataPocoImpl.cs | 2 +- 12 files changed, 81 insertions(+), 50 deletions(-) create mode 100644 src/NHibernate.Test/CacheTest/CacheEntity.cs create mode 100644 src/NHibernate.Test/CacheTest/NonStrictReadWrite.cs create mode 100644 src/NHibernate.Test/CacheTest/NonStrictReadWrite.hbm.xml diff --git a/src/NHibernate.Test/CacheTest/CacheEntity.cs b/src/NHibernate.Test/CacheTest/CacheEntity.cs new file mode 100644 index 00000000000..cf8669430cf --- /dev/null +++ b/src/NHibernate.Test/CacheTest/CacheEntity.cs @@ -0,0 +1,11 @@ +namespace NHibernate.Test.CacheTest; + +public abstract class CacheEntity +{ + public virtual int Id { get; protected set; } +} + +public abstract class NamedCacheEntity : CacheEntity +{ + public virtual string Name { get; set; } +} diff --git a/src/NHibernate.Test/CacheTest/JsonSerializerCacheFixture.cs b/src/NHibernate.Test/CacheTest/JsonSerializerCacheFixture.cs index bf1009c90d3..7c704f3dd3d 100644 --- a/src/NHibernate.Test/CacheTest/JsonSerializerCacheFixture.cs +++ b/src/NHibernate.Test/CacheTest/JsonSerializerCacheFixture.cs @@ -19,7 +19,8 @@ public class JsonSerializerCacheFixture : TestCase protected override string[] Mappings => new[] { "CacheTest.ReadOnly.hbm.xml", - "CacheTest.ReadWrite.hbm.xml" + "CacheTest.ReadWrite.hbm.xml", + "CacheTest.NonStrictReadWrite.hbm.xml" }; protected override string MappingsAssembly => "NHibernate.Test"; @@ -35,7 +36,7 @@ protected override void Configure(Configuration configuration) serializer.RegisterType(typeof(Tuple), "tso"); CoreDistributedCacheProvider.DefaultSerializer = serializer; } - + protected override void OnSetUp() { using (var s = Sfi.OpenSession()) @@ -44,36 +45,27 @@ protected override void OnSetUp() var totalItems = 6; for (var i = 1; i <= totalItems; i++) { - var parent = new ReadOnly - { - Name = $"Name{i}" - }; + var parent = new ReadOnly { Name = $"Name{i}" }; for (var j = 1; j <= totalItems; j++) { - var child = new ReadOnlyItem - { - Parent = parent - }; - parent.Items.Add(child); + parent.Items.Add(new ReadOnlyItem { Parent = parent }); } s.Save(parent); } for (var i = 1; i <= totalItems; i++) { - var parent = new ReadWrite - { - Name = $"Name{i}" - }; + var parent = new ReadWrite { Name = $"Name{i}" }; for (var j = 1; j <= totalItems; j++) { - var child = new ReadWriteItem - { - Parent = parent - }; - parent.Items.Add(child); + parent.Items.Add(new ReadWriteItem { Parent = parent }); } s.Save(parent); } + for (var i = 1; i <= totalItems; i++) + { + var parent = new NonStrictReadWrite() { Name = $"Name{i}" }; + s.Save(parent); + } tx.Commit(); } } @@ -87,12 +79,13 @@ protected override void OnTearDown() s.CreateQuery("delete from ReadWriteItem").ExecuteUpdate(); s.CreateQuery("delete from ReadOnly").ExecuteUpdate(); s.CreateQuery("delete from ReadWrite").ExecuteUpdate(); + s.CreateQuery("delete from NonStrictReadWrite").ExecuteUpdate(); tx.Commit(); } // Must rebuild the session factory, CoreDistribted cache being not clearable. RebuildSessionFactory(); } - + [Test] public void CacheableScalarSqlQueryWithTransformer() { @@ -521,5 +514,20 @@ public void QueryFetchEntityBatchCacheTest(bool future) Assert.That(Sfi.Statistics.QueryCacheMissCount, Is.EqualTo(0), "Unexpected cache miss count"); Assert.That(Sfi.Statistics.QueryCacheHitCount, Is.EqualTo(future ? 2 : 1), "Unexpected cache hit count"); } + + [Test] + public void LazyFormulaTest() + { + using (var s = OpenSession()) + using (var t = s.BeginTransaction()) + { + var l = s.Query().ToList(); + foreach (var item in l) + { + Assert.AreEqual(item.Id, item.Count); + } + t.Commit(); + } + } } } diff --git a/src/NHibernate.Test/CacheTest/NonStrictReadWrite.cs b/src/NHibernate.Test/CacheTest/NonStrictReadWrite.cs new file mode 100644 index 00000000000..fa3f6f544fc --- /dev/null +++ b/src/NHibernate.Test/CacheTest/NonStrictReadWrite.cs @@ -0,0 +1,7 @@ +namespace NHibernate.Test.CacheTest +{ + public class NonStrictReadWrite : NamedCacheEntity + { + public virtual int Count { get; set; } + } +} diff --git a/src/NHibernate.Test/CacheTest/NonStrictReadWrite.hbm.xml b/src/NHibernate.Test/CacheTest/NonStrictReadWrite.hbm.xml new file mode 100644 index 00000000000..1caaf812c62 --- /dev/null +++ b/src/NHibernate.Test/CacheTest/NonStrictReadWrite.hbm.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/src/NHibernate.Test/CacheTest/ReadOnly.cs b/src/NHibernate.Test/CacheTest/ReadOnly.cs index c509e0cc2ec..b1ac5ee0742 100644 --- a/src/NHibernate.Test/CacheTest/ReadOnly.cs +++ b/src/NHibernate.Test/CacheTest/ReadOnly.cs @@ -1,15 +1,9 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Collections.Generic; namespace NHibernate.Test.CacheTest { - public class ReadOnly : CacheEntity + public class ReadOnly : NamedCacheEntity { - public virtual string Name { get; set; } - public virtual ISet Items { get; set; } = new HashSet(); } @@ -17,9 +11,4 @@ public class ReadOnlyItem : CacheEntity { public virtual ReadOnly Parent { get; set; } } - - public abstract class CacheEntity - { - public virtual int Id { get; protected set; } - } } diff --git a/src/NHibernate.Test/CacheTest/ReadWrite.cs b/src/NHibernate.Test/CacheTest/ReadWrite.cs index f08added0e2..f9875e82038 100644 --- a/src/NHibernate.Test/CacheTest/ReadWrite.cs +++ b/src/NHibernate.Test/CacheTest/ReadWrite.cs @@ -1,15 +1,9 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Collections.Generic; namespace NHibernate.Test.CacheTest { - public class ReadWrite : CacheEntity + public class ReadWrite : NamedCacheEntity { - public virtual string Name { get; set; } - public virtual ISet Items { get; set; } = new HashSet(); } diff --git a/src/NHibernate.Test/NHibernate.Test.csproj b/src/NHibernate.Test/NHibernate.Test.csproj index ff728f12045..542f7b68e36 100644 --- a/src/NHibernate.Test/NHibernate.Test.csproj +++ b/src/NHibernate.Test/NHibernate.Test.csproj @@ -52,6 +52,7 @@ + diff --git a/src/NHibernate.TestDatabaseSetup/App.config b/src/NHibernate.TestDatabaseSetup/App.config index 1fa471187b0..ea4dfd2723b 100644 --- a/src/NHibernate.TestDatabaseSetup/App.config +++ b/src/NHibernate.TestDatabaseSetup/App.config @@ -9,7 +9,7 @@ NHibernate.Driver.Sql2008ClientDriver - Server=.\SQLExpress;initial catalog=master;Integrated Security=SSPI + Server=(localdb)\MSSQLLocalDB;Database=NhTestDb;Integrated Security=true; diff --git a/src/NHibernate/Engine/IPersistenceContext.cs b/src/NHibernate/Engine/IPersistenceContext.cs index f23f9731788..05a3070aebe 100644 --- a/src/NHibernate/Engine/IPersistenceContext.cs +++ b/src/NHibernate/Engine/IPersistenceContext.cs @@ -457,7 +457,7 @@ public static EntityEntry AddEntity( existsInDatabase, persister, disableVersionIncrement, - loadedState?.Any(o => o == LazyPropertyInitializer.UnfetchedProperty) == true); + loadedState?.Any(o => Equals(o, LazyPropertyInitializer.UnfetchedProperty)) == true); #pragma warning restore 618 } @@ -505,7 +505,7 @@ public static EntityEntry AddEntry( existsInDatabase, persister, disableVersionIncrement, - loadedState?.Any(o => o == LazyPropertyInitializer.UnfetchedProperty) == true); + loadedState?.Any(o => Equals(o, LazyPropertyInitializer.UnfetchedProperty)) == true); #pragma warning restore 618 } } diff --git a/src/NHibernate/Intercept/ILazyPropertyInitializer.cs b/src/NHibernate/Intercept/ILazyPropertyInitializer.cs index d167f5a56f5..0852d37ca1a 100644 --- a/src/NHibernate/Intercept/ILazyPropertyInitializer.cs +++ b/src/NHibernate/Intercept/ILazyPropertyInitializer.cs @@ -4,8 +4,14 @@ namespace NHibernate.Intercept { [Serializable] - public struct UnfetchedLazyProperty + public struct UnfetchedLazyProperty : IEquatable { + /// As long as the other instance is the same type, it's considered equal. This + /// avoids the issue where a deserialized value fails the base Object.Equals() check due to + /// both objects being different references. + public bool Equals(UnfetchedLazyProperty other) => true; + public override bool Equals(object obj) => obj is UnfetchedLazyProperty; + public override int GetHashCode() => typeof(UnfetchedLazyProperty).GetHashCode(); } public struct LazyPropertyInitializer @@ -20,4 +26,4 @@ public interface ILazyPropertyInitializer /// Initialize the property, and return its new value object InitializeLazyProperty(string fieldName, object entity, ISessionImplementor session); } -} \ No newline at end of file +} diff --git a/src/NHibernate/Persister/Entity/AbstractEntityPersister.cs b/src/NHibernate/Persister/Entity/AbstractEntityPersister.cs index 77729249b62..f00daf1af31 100644 --- a/src/NHibernate/Persister/Entity/AbstractEntityPersister.cs +++ b/src/NHibernate/Persister/Entity/AbstractEntityPersister.cs @@ -1396,7 +1396,7 @@ public virtual object InitializeLazyProperty(string fieldName, object entity, IS { CacheEntry cacheEntry = (CacheEntry)CacheEntryStructure.Destructure(ce, factory); var initializedValue = InitializeLazyPropertiesFromCache(fieldName, entity, session, entry, cacheEntry, uninitializedLazyProperties); - if (initializedValue != LazyPropertyInitializer.UnfetchedProperty) + if (!Equals(initializedValue, LazyPropertyInitializer.UnfetchedProperty)) { // NOTE EARLY EXIT!!! return initializedValue; @@ -1567,7 +1567,7 @@ private object InitializeLazyPropertiesFromCache( for (int j = 0; j < lazyPropertyNames.Length; j++) { var cachedValue = disassembledValues[lazyPropertyNumbers[j]]; - if (cachedValue == LazyPropertyInitializer.UnfetchedProperty) + if (Equals(cachedValue, LazyPropertyInitializer.UnfetchedProperty)) { if (fieldName == lazyPropertyNames[j]) { diff --git a/src/NHibernate/Tuple/Entity/BytecodeEnhancementMetadataPocoImpl.cs b/src/NHibernate/Tuple/Entity/BytecodeEnhancementMetadataPocoImpl.cs index e0adb041feb..5904bb0132f 100644 --- a/src/NHibernate/Tuple/Entity/BytecodeEnhancementMetadataPocoImpl.cs +++ b/src/NHibernate/Tuple/Entity/BytecodeEnhancementMetadataPocoImpl.cs @@ -202,7 +202,7 @@ public ISet GetUninitializedLazyProperties(object[] entityState) var uninitializedProperties = new HashSet(); foreach (var propertyDescriptor in LazyPropertiesMetadata.LazyPropertyDescriptors) { - if (entityState[propertyDescriptor.PropertyIndex] == LazyPropertyInitializer.UnfetchedProperty) + if (Equals(entityState[propertyDescriptor.PropertyIndex], LazyPropertyInitializer.UnfetchedProperty)) { uninitializedProperties.Add(propertyDescriptor.Name); } From a73a926b09c50d98d15fd13aa9300aec4691a190 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 1 Dec 2025 23:13:31 +0000 Subject: [PATCH 2/5] Generate async files --- .../CacheTest/JsonSerializerCacheFixture.cs | 50 +++++++++++-------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/src/NHibernate.Test/Async/CacheTest/JsonSerializerCacheFixture.cs b/src/NHibernate.Test/Async/CacheTest/JsonSerializerCacheFixture.cs index 01403997a46..be213b86dc0 100644 --- a/src/NHibernate.Test/Async/CacheTest/JsonSerializerCacheFixture.cs +++ b/src/NHibernate.Test/Async/CacheTest/JsonSerializerCacheFixture.cs @@ -30,7 +30,8 @@ public class JsonSerializerCacheFixtureAsync : TestCase protected override string[] Mappings => new[] { "CacheTest.ReadOnly.hbm.xml", - "CacheTest.ReadWrite.hbm.xml" + "CacheTest.ReadWrite.hbm.xml", + "CacheTest.NonStrictReadWrite.hbm.xml" }; protected override string MappingsAssembly => "NHibernate.Test"; @@ -46,7 +47,7 @@ protected override void Configure(Configuration configuration) serializer.RegisterType(typeof(Tuple), "tso"); CoreDistributedCacheProvider.DefaultSerializer = serializer; } - + protected override void OnSetUp() { using (var s = Sfi.OpenSession()) @@ -55,36 +56,27 @@ protected override void OnSetUp() var totalItems = 6; for (var i = 1; i <= totalItems; i++) { - var parent = new ReadOnly - { - Name = $"Name{i}" - }; + var parent = new ReadOnly { Name = $"Name{i}" }; for (var j = 1; j <= totalItems; j++) { - var child = new ReadOnlyItem - { - Parent = parent - }; - parent.Items.Add(child); + parent.Items.Add(new ReadOnlyItem { Parent = parent }); } s.Save(parent); } for (var i = 1; i <= totalItems; i++) { - var parent = new ReadWrite - { - Name = $"Name{i}" - }; + var parent = new ReadWrite { Name = $"Name{i}" }; for (var j = 1; j <= totalItems; j++) { - var child = new ReadWriteItem - { - Parent = parent - }; - parent.Items.Add(child); + parent.Items.Add(new ReadWriteItem { Parent = parent }); } s.Save(parent); } + for (var i = 1; i <= totalItems; i++) + { + var parent = new NonStrictReadWrite() { Name = $"Name{i}" }; + s.Save(parent); + } tx.Commit(); } } @@ -98,12 +90,13 @@ protected override void OnTearDown() s.CreateQuery("delete from ReadWriteItem").ExecuteUpdate(); s.CreateQuery("delete from ReadOnly").ExecuteUpdate(); s.CreateQuery("delete from ReadWrite").ExecuteUpdate(); + s.CreateQuery("delete from NonStrictReadWrite").ExecuteUpdate(); tx.Commit(); } // Must rebuild the session factory, CoreDistribted cache being not clearable. RebuildSessionFactory(); } - + [Test] public async Task CacheableScalarSqlQueryWithTransformerAsync() { @@ -532,5 +525,20 @@ public async Task QueryFetchEntityBatchCacheTestAsync(bool future) Assert.That(Sfi.Statistics.QueryCacheMissCount, Is.EqualTo(0), "Unexpected cache miss count"); Assert.That(Sfi.Statistics.QueryCacheHitCount, Is.EqualTo(future ? 2 : 1), "Unexpected cache hit count"); } + + [Test] + public async Task LazyFormulaTestAsync() + { + using (var s = OpenSession()) + using (var t = s.BeginTransaction()) + { + var l = await (s.Query().ToListAsync()); + foreach (var item in l) + { + Assert.AreEqual(item.Id, item.Count); + } + await (t.CommitAsync()); + } + } } } From e99c2dc90fcaab3016c6601e083c7ce140ca83f4 Mon Sep 17 00:00:00 2001 From: Jeff Hagen Date: Mon, 1 Dec 2025 18:16:55 -0500 Subject: [PATCH 3/5] oops revert --- src/NHibernate.TestDatabaseSetup/App.config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NHibernate.TestDatabaseSetup/App.config b/src/NHibernate.TestDatabaseSetup/App.config index ea4dfd2723b..1fa471187b0 100644 --- a/src/NHibernate.TestDatabaseSetup/App.config +++ b/src/NHibernate.TestDatabaseSetup/App.config @@ -9,7 +9,7 @@ NHibernate.Driver.Sql2008ClientDriver - Server=(localdb)\MSSQLLocalDB;Database=NhTestDb;Integrated Security=true; + Server=.\SQLExpress;initial catalog=master;Integrated Security=SSPI From af8c081834f9a620603a698f2e185369e16c03da Mon Sep 17 00:00:00 2001 From: Jeff Hagen Date: Tue, 2 Dec 2025 08:27:09 -0500 Subject: [PATCH 4/5] fix unit tests --- src/NHibernate.Test/CacheTest/JsonSerializerCacheFixture.cs | 4 +++- src/NHibernate.Test/CacheTest/NonStrictReadWrite.hbm.xml | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/NHibernate.Test/CacheTest/JsonSerializerCacheFixture.cs b/src/NHibernate.Test/CacheTest/JsonSerializerCacheFixture.cs index 7c704f3dd3d..ea6e2f8dd30 100644 --- a/src/NHibernate.Test/CacheTest/JsonSerializerCacheFixture.cs +++ b/src/NHibernate.Test/CacheTest/JsonSerializerCacheFixture.cs @@ -524,7 +524,9 @@ public void LazyFormulaTest() var l = s.Query().ToList(); foreach (var item in l) { - Assert.AreEqual(item.Id, item.Count); + // The lazy formula will puke if equality is not correct (due to comparison of deserialized + // UnfetchedLazyProperty vs LazyPropertyInitializer.UnfetchedProperty + Assert.AreEqual(1, item.Count); } t.Commit(); } diff --git a/src/NHibernate.Test/CacheTest/NonStrictReadWrite.hbm.xml b/src/NHibernate.Test/CacheTest/NonStrictReadWrite.hbm.xml index 1caaf812c62..09fc23c3ed5 100644 --- a/src/NHibernate.Test/CacheTest/NonStrictReadWrite.hbm.xml +++ b/src/NHibernate.Test/CacheTest/NonStrictReadWrite.hbm.xml @@ -9,7 +9,7 @@ - + From da31c15054a3db241d74c48dbb6da51de54faba4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 2 Dec 2025 13:29:15 +0000 Subject: [PATCH 5/5] Generate async files --- .../Async/CacheTest/JsonSerializerCacheFixture.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/NHibernate.Test/Async/CacheTest/JsonSerializerCacheFixture.cs b/src/NHibernate.Test/Async/CacheTest/JsonSerializerCacheFixture.cs index be213b86dc0..d473758ff70 100644 --- a/src/NHibernate.Test/Async/CacheTest/JsonSerializerCacheFixture.cs +++ b/src/NHibernate.Test/Async/CacheTest/JsonSerializerCacheFixture.cs @@ -535,7 +535,9 @@ public async Task LazyFormulaTestAsync() var l = await (s.Query().ToListAsync()); foreach (var item in l) { - Assert.AreEqual(item.Id, item.Count); + // The lazy formula will puke if equality is not correct (due to comparison of deserialized + // UnfetchedLazyProperty vs LazyPropertyInitializer.UnfetchedProperty + Assert.AreEqual(1, item.Count); } await (t.CommitAsync()); }