diff --git a/src/NHibernate.Test/Async/NHSpecificTest/GH3609/Fixture.cs b/src/NHibernate.Test/Async/NHSpecificTest/GH3609/Fixture.cs
new file mode 100644
index 00000000000..55554bf75f6
--- /dev/null
+++ b/src/NHibernate.Test/Async/NHSpecificTest/GH3609/Fixture.cs
@@ -0,0 +1,114 @@
+//------------------------------------------------------------------------------
+//
+// This code was generated by AsyncGenerator.
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+
+
+using System;
+using System.Linq;
+using NUnit.Framework;
+using NHibernate.Linq;
+
+namespace NHibernate.Test.NHSpecificTest.GH3609
+{
+ using System.Threading.Tasks;
+ [TestFixture]
+ public class FixtureAsync : BugTestCase
+ {
+ protected override void OnSetUp()
+ {
+ using var session = OpenSession();
+ using var transaction = session.BeginTransaction();
+
+ var order = new Order
+ {
+ UniqueId = "0ab92479-8a17-4dbc-9bef-ce4344940cec",
+ CreatedDate = new DateTime(2024, 09, 24)
+ };
+ session.Save(order);
+ session.Save(new LineItem { Order = order, ItemName = "Bananas", Amount = 5 });
+ session.Save(new CleanLineItem { Order = order, ItemName = "Bananas", Amount = 5 });
+
+ order = new Order
+ {
+ UniqueId = "4ca17d84-97aa-489f-8701-302a3879a388",
+ CreatedDate = new DateTime(2021, 09, 19)
+ };
+ session.Save(order);
+ session.Save(new LineItem { Order = order, ItemName = "Apples", Amount = 10 });
+ session.Save(new CleanLineItem { Order = order, ItemName = "Apples", Amount = 10 });
+
+ transaction.Commit();
+ }
+
+ protected override void OnTearDown()
+ {
+ using var session = OpenSession();
+ using var transaction = session.BeginTransaction();
+
+ session.CreateQuery("delete from CleanLineItem").ExecuteUpdate();
+ session.CreateQuery("delete from System.Object").ExecuteUpdate();
+
+ transaction.Commit();
+ }
+
+ [Test]
+ public async Task QueryWithAnyAsync()
+ {
+ using var session = OpenSession();
+ using var transaction = session.BeginTransaction();
+
+ // This form of query is how we first discovered the issue. This is a simplified reproduction of the
+ // sort of Linq that we were using in our app. It seems to occur when we force an EXISTS( ... ) subquery.
+ var validOrders = session.Query().Where(x => x.CreatedDate > new DateTime(2024, 9, 10));
+ var orderCount = await (session.Query().CountAsync(x => validOrders.Any(y => y == x.Order)));
+
+ Assert.That(orderCount, Is.EqualTo(1));
+ await (transaction.CommitAsync());
+ }
+
+ [Test]
+ public async Task QueryWithAnyOnCleanLinesAsync()
+ {
+ using var session = OpenSession();
+ using var transaction = session.BeginTransaction();
+
+ // This form of query is how we first discovered the issue. This is a simplified reproduction of the
+ // sort of Linq that we were using in our app. It seems to occur when we force an EXISTS( ... ) subquery.
+ var validOrders = session.Query().Where(x => x.CreatedDate > new DateTime(2024, 9, 10));
+ var orderCount = await (session.Query().CountAsync(x => validOrders.Any(y => y == x.Order)));
+
+ Assert.That(orderCount, Is.EqualTo(1));
+ await (transaction.CommitAsync());
+ }
+
+ [Test]
+ public async Task QueryWithContainsAsync()
+ {
+ using var session = OpenSession();
+ using var transaction = session.BeginTransaction();
+
+ var validOrders = session.Query().Where(x => x.CreatedDate > new DateTime(2024, 9, 10));
+ var orderCount = await (session.Query().CountAsync(x => validOrders.Contains(x.Order)));
+
+ Assert.That(orderCount, Is.EqualTo(1));
+ await (transaction.CommitAsync());
+ }
+
+ [Test]
+ public async Task SimpleQueryForDataWhichWasInsertedViaAdoShouldProvideExpectedResultsAsync()
+ {
+ using var session = OpenSession();
+ using var transaction = session.BeginTransaction();
+
+ // This style of equivalent query does not exhibit the problem. This test passes no matter which NH version.
+ var lineItem = await (session.Query().FirstOrDefaultAsync(x => x.Order.CreatedDate > new DateTime(2024, 9, 10)));
+ Assert.That(lineItem, Is.Not.Null);
+ await (transaction.CommitAsync());
+ }
+ }
+}
diff --git a/src/NHibernate.Test/NHSpecificTest/GH3609/Entities.cs b/src/NHibernate.Test/NHSpecificTest/GH3609/Entities.cs
new file mode 100644
index 00000000000..79489468534
--- /dev/null
+++ b/src/NHibernate.Test/NHSpecificTest/GH3609/Entities.cs
@@ -0,0 +1,35 @@
+using System;
+
+namespace NHibernate.Test.NHSpecificTest.GH3609
+{
+ public class Order
+ {
+ public virtual long Id { get; set; }
+
+ public virtual string UniqueId { get; set; } = Guid.NewGuid().ToString();
+
+ public virtual DateTime CreatedDate { get; set; }
+ }
+
+ public class LineItem
+ {
+ public virtual long Id { get; set; }
+
+ public virtual Order Order { get; set; }
+
+ public virtual string ItemName { get; set; }
+
+ public virtual decimal Amount { get; set; }
+ }
+
+ public class CleanLineItem
+ {
+ public virtual long Id { get; set; }
+
+ public virtual Order Order { get; set; }
+
+ public virtual string ItemName { get; set; }
+
+ public virtual decimal Amount { get; set; }
+ }
+}
diff --git a/src/NHibernate.Test/NHSpecificTest/GH3609/Fixture.cs b/src/NHibernate.Test/NHSpecificTest/GH3609/Fixture.cs
new file mode 100644
index 00000000000..57a0a8053dd
--- /dev/null
+++ b/src/NHibernate.Test/NHSpecificTest/GH3609/Fixture.cs
@@ -0,0 +1,102 @@
+using System;
+using System.Linq;
+using NUnit.Framework;
+
+namespace NHibernate.Test.NHSpecificTest.GH3609
+{
+ [TestFixture]
+ public class Fixture : BugTestCase
+ {
+ protected override void OnSetUp()
+ {
+ using var session = OpenSession();
+ using var transaction = session.BeginTransaction();
+
+ var order = new Order
+ {
+ UniqueId = "0ab92479-8a17-4dbc-9bef-ce4344940cec",
+ CreatedDate = new DateTime(2024, 09, 24)
+ };
+ session.Save(order);
+ session.Save(new LineItem { Order = order, ItemName = "Bananas", Amount = 5 });
+ session.Save(new CleanLineItem { Order = order, ItemName = "Bananas", Amount = 5 });
+
+ order = new Order
+ {
+ UniqueId = "4ca17d84-97aa-489f-8701-302a3879a388",
+ CreatedDate = new DateTime(2021, 09, 19)
+ };
+ session.Save(order);
+ session.Save(new LineItem { Order = order, ItemName = "Apples", Amount = 10 });
+ session.Save(new CleanLineItem { Order = order, ItemName = "Apples", Amount = 10 });
+
+ transaction.Commit();
+ }
+
+ protected override void OnTearDown()
+ {
+ using var session = OpenSession();
+ using var transaction = session.BeginTransaction();
+
+ session.CreateQuery("delete from CleanLineItem").ExecuteUpdate();
+ session.CreateQuery("delete from System.Object").ExecuteUpdate();
+
+ transaction.Commit();
+ }
+
+ [Test]
+ public void QueryWithAny()
+ {
+ using var session = OpenSession();
+ using var transaction = session.BeginTransaction();
+
+ // This form of query is how we first discovered the issue. This is a simplified reproduction of the
+ // sort of Linq that we were using in our app. It seems to occur when we force an EXISTS( ... ) subquery.
+ var validOrders = session.Query().Where(x => x.CreatedDate > new DateTime(2024, 9, 10));
+ var orderCount = session.Query().Count(x => validOrders.Any(y => y == x.Order));
+
+ Assert.That(orderCount, Is.EqualTo(1));
+ transaction.Commit();
+ }
+
+ [Test]
+ public void QueryWithAnyOnCleanLines()
+ {
+ using var session = OpenSession();
+ using var transaction = session.BeginTransaction();
+
+ // This form of query is how we first discovered the issue. This is a simplified reproduction of the
+ // sort of Linq that we were using in our app. It seems to occur when we force an EXISTS( ... ) subquery.
+ var validOrders = session.Query().Where(x => x.CreatedDate > new DateTime(2024, 9, 10));
+ var orderCount = session.Query().Count(x => validOrders.Any(y => y == x.Order));
+
+ Assert.That(orderCount, Is.EqualTo(1));
+ transaction.Commit();
+ }
+
+ [Test]
+ public void QueryWithContains()
+ {
+ using var session = OpenSession();
+ using var transaction = session.BeginTransaction();
+
+ var validOrders = session.Query().Where(x => x.CreatedDate > new DateTime(2024, 9, 10));
+ var orderCount = session.Query().Count(x => validOrders.Contains(x.Order));
+
+ Assert.That(orderCount, Is.EqualTo(1));
+ transaction.Commit();
+ }
+
+ [Test]
+ public void SimpleQueryForDataWhichWasInsertedViaAdoShouldProvideExpectedResults()
+ {
+ using var session = OpenSession();
+ using var transaction = session.BeginTransaction();
+
+ // This style of equivalent query does not exhibit the problem. This test passes no matter which NH version.
+ var lineItem = session.Query().FirstOrDefault(x => x.Order.CreatedDate > new DateTime(2024, 9, 10));
+ Assert.That(lineItem, Is.Not.Null);
+ transaction.Commit();
+ }
+ }
+}
diff --git a/src/NHibernate.Test/NHSpecificTest/GH3609/Mappings.hbm.xml b/src/NHibernate.Test/NHSpecificTest/GH3609/Mappings.hbm.xml
new file mode 100644
index 00000000000..43191f32951
--- /dev/null
+++ b/src/NHibernate.Test/NHSpecificTest/GH3609/Mappings.hbm.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/NHibernate/Hql/Ast/ANTLR/Tree/DotNode.cs b/src/NHibernate/Hql/Ast/ANTLR/Tree/DotNode.cs
index 4b0f127a2ca..a7d416845dc 100644
--- a/src/NHibernate/Hql/Ast/ANTLR/Tree/DotNode.cs
+++ b/src/NHibernate/Hql/Ast/ANTLR/Tree/DotNode.cs
@@ -161,7 +161,7 @@ public override void ResolveFirstChild()
string propName = property.Text;
_propertyName = propName;
- // If the uresolved property path isn't set yet, just use the property name.
+ // If the unresolved property path isn't set yet, just use the property name.
if (_propertyPath == null)
{
_propertyPath = propName;
@@ -397,10 +397,14 @@ private void DereferenceEntity(EntityType entityType, bool implicitJoin, string
string property = _propertyName;
bool joinIsNeeded;
- //For nullable entity comparisons we always need to add join (like not constrained one-to-one or not-found ignore associations)
- bool comparisonWithNullableEntity = entityType.IsNullable && Walker.IsComparativeExpressionClause && !IsCorrelatedSubselect;
+ // For nullable entity comparisons we always need to add join (like not constrained one-to-one or not-found ignore associations).
+ var comparisonWithNullableEntity = Walker.IsComparativeExpressionClause && entityType.IsNullable;
+ // For property-ref association comparison, we also need to join unless finding a way in the node for the other hand of the comparison
+ // to detect it should yield the property-ref columns instead of the primary key columns. And if the other hand is an association too,
+ // it may be a reference to the primary key, so we would need to join anyway.
+ var comparisonThroughPropertyRef = Walker.IsComparativeExpressionClause && !entityType.IsReferenceToPrimaryKey;
- if ( IsDotNode( parent ) )
+ if (IsDotNode(parent))
{
// our parent is another dot node, meaning we are being further dereferenced.
// thus we need to generate a join unless the parent refers to the associated
@@ -421,15 +425,18 @@ private void DereferenceEntity(EntityType entityType, bool implicitJoin, string
else
{
joinIsNeeded = generateJoin || (Walker.IsInSelect && !Walker.IsInCase) || (Walker.IsInFrom && !Walker.IsComparativeExpressionClause)
- || comparisonWithNullableEntity;
+ || comparisonWithNullableEntity || comparisonThroughPropertyRef;
}
if ( joinIsNeeded )
{
- DereferenceEntityJoin(classAlias, entityType, implicitJoin, parent, comparisonWithNullableEntity);
- if (comparisonWithNullableEntity)
+ // Subselect queries use theta style joins, which cannot be forced to left outer joins.
+ var forceLeftJoin = comparisonWithNullableEntity && !IsCorrelatedSubselect;
+ DereferenceEntityJoin(classAlias, entityType, implicitJoin, parent, forceLeftJoin);
+ if (comparisonWithNullableEntity || comparisonThroughPropertyRef)
{
_columns = FromElement.GetIdentityColumns();
+ DataType = FromElement.EntityPersister.EntityMetamodel.EntityType;
}
}
else