From 5068f8c8213c60c6bc34e8b5cc86e3147f305898 Mon Sep 17 00:00:00 2001 From: Smit Patel Date: Wed, 18 Nov 2020 16:55:18 -0800 Subject: [PATCH] Query: Assign nullability to entity shaper correctly for set operations (#23227) Also improved In-Memory set operation implementation Resolves #19253 --- .../Query/Internal/InMemoryQueryExpression.cs | 104 +++++++ ...yableMethodTranslatingExpressionVisitor.cs | 63 +++- .../Query/RelationalEntityShaperExpression.cs | 9 +- ...yableMethodTranslatingExpressionVisitor.cs | 57 +++- .../Query/SqlExpressions/SelectExpression.cs | 2 +- src/EFCore/Query/EntityShaperExpression.cs | 13 +- ...yableMethodTranslatingExpressionVisitor.cs | 2 +- .../Query/QueryBugsInMemoryTest.cs | 235 +++++++++++++++ .../Query/QueryBugsTest.cs | 275 ++++++++++++++++++ 9 files changed, 742 insertions(+), 18 deletions(-) diff --git a/src/EFCore.InMemory/Query/Internal/InMemoryQueryExpression.cs b/src/EFCore.InMemory/Query/Internal/InMemoryQueryExpression.cs index c0814c7b1a7..21596d1ad8d 100644 --- a/src/EFCore.InMemory/Query/Internal/InMemoryQueryExpression.cs +++ b/src/EFCore.InMemory/Query/Internal/InMemoryQueryExpression.cs @@ -379,6 +379,110 @@ public virtual void PushdownIntoSubquery() } } + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public virtual void ApplySetOperation([NotNull] MethodInfo setOperationMethodInfo, [NotNull] InMemoryQueryExpression source2) + { + var clientProjection = _valueBufferSlots.Count != 0; + if (!clientProjection) + { + var result = new Dictionary(); + foreach (var (key, value1, value2) in _projectionMapping.Join( + source2._projectionMapping, kv => kv.Key, kv => kv.Key, + (kv1, kv2) => (kv1.Key, Value1: kv1.Value, Value2: kv2.Value))) + { + if (value1 is EntityProjectionExpression entityProjection1 + && value2 is EntityProjectionExpression entityProjection2) + { + var map = new Dictionary(); + foreach (var property in GetAllPropertiesInHierarchy(entityProjection1.EntityType)) + { + var expressionToAdd1 = entityProjection1.BindProperty(property); + var expressionToAdd2 = entityProjection2.BindProperty(property); + var index = AddToProjection(expressionToAdd1); + source2.AddToProjection(expressionToAdd2); + var type = expressionToAdd1.Type; + if (!type.IsNullableType() + && expressionToAdd2.Type.IsNullableType()) + { + type = expressionToAdd2.Type; + } + map[property] = CreateReadValueExpression(type, index, property); + } + + result[key] = new EntityProjectionExpression(entityProjection1.EntityType, map); + } + else + { + var index = AddToProjection(value1); + source2.AddToProjection(value2); + var type = value1.Type; + if (!type.IsNullableType() + && value2.Type.IsNullableType()) + { + type = value2.Type; + } + result[key] = CreateReadValueExpression(type, index, InferPropertyFromInner(value1)); + } + } + + _projectionMapping = result; + } + + var selectorLambda = Lambda( + New( + _valueBufferConstructor, + NewArrayInit( + typeof(object), + _valueBufferSlots + .Select(e => e.Type.IsValueType ? Convert(e, typeof(object)) : e))), + CurrentParameter); + + _groupingParameter = null; + + ServerQueryExpression = Call( + EnumerableMethods.Select.MakeGenericMethod(ServerQueryExpression.Type.TryGetSequenceType(), typeof(ValueBuffer)), + ServerQueryExpression, + selectorLambda); + + var selectorLambda2 = Lambda( + New( + _valueBufferConstructor, + NewArrayInit( + typeof(object), + source2._valueBufferSlots + .Select(e => e.Type.IsValueType ? Convert(e, typeof(object)) : e))), + source2.CurrentParameter); + + source2._groupingParameter = null; + + source2.ServerQueryExpression = Call( + EnumerableMethods.Select.MakeGenericMethod(source2.ServerQueryExpression.Type.TryGetSequenceType(), typeof(ValueBuffer)), + source2.ServerQueryExpression, + selectorLambda2); + + ServerQueryExpression = Call( + setOperationMethodInfo.MakeGenericMethod(typeof(ValueBuffer)), ServerQueryExpression, source2.ServerQueryExpression); + + if (clientProjection) + { + var newValueBufferSlots = _valueBufferSlots + .Select((e, i) => CreateReadValueExpression(e.Type, i, InferPropertyFromInner(e))) + .ToList(); + + _valueBufferSlots.Clear(); + _valueBufferSlots.AddRange(newValueBufferSlots); + } + else + { + _valueBufferSlots.Clear(); + } + } + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore.InMemory/Query/Internal/InMemoryQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.InMemory/Query/Internal/InMemoryQueryableMethodTranslatingExpressionVisitor.cs index 0d0f8d92f6a..8d236186d5a 100644 --- a/src/EFCore.InMemory/Query/Internal/InMemoryQueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore.InMemory/Query/Internal/InMemoryQueryableMethodTranslatingExpressionVisitor.cs @@ -1588,18 +1588,61 @@ private ShapedQueryExpression TranslateSetOperation( var inMemoryQueryExpression1 = (InMemoryQueryExpression)source1.QueryExpression; var inMemoryQueryExpression2 = (InMemoryQueryExpression)source2.QueryExpression; - // Apply any pending selectors, ensuring that the shape of both expressions is identical - // prior to applying the set operation. - inMemoryQueryExpression1.PushdownIntoSubquery(); - inMemoryQueryExpression2.PushdownIntoSubquery(); + inMemoryQueryExpression1.ApplySetOperation(setOperationMethodInfo, inMemoryQueryExpression2); + + if (setOperationMethodInfo.Equals(EnumerableMethods.Except)) + { + return source1; + } + + var makeNullable = setOperationMethodInfo != EnumerableMethods.Intersect; + + return source1.UpdateShaperExpression(MatchShaperNullabilityForSetOperation( + source1.ShaperExpression, source2.ShaperExpression, makeNullable)); + } + + private Expression MatchShaperNullabilityForSetOperation(Expression shaper1, Expression shaper2, bool makeNullable) + { + switch (shaper1) + { + case EntityShaperExpression entityShaperExpression1 + when shaper2 is EntityShaperExpression entityShaperExpression2: + return entityShaperExpression1.IsNullable != entityShaperExpression2.IsNullable + ? entityShaperExpression1.MakeNullable(makeNullable) + : entityShaperExpression1; + + case NewExpression newExpression1 + when shaper2 is NewExpression newExpression2: + var newArguments = new Expression[newExpression1.Arguments.Count]; + for (var i = 0; i < newArguments.Length; i++) + { + newArguments[i] = MatchShaperNullabilityForSetOperation( + newExpression1.Arguments[i], newExpression2.Arguments[i], makeNullable); + } + + return newExpression1.Update(newArguments); + + case MemberInitExpression memberInitExpression1 + when shaper2 is MemberInitExpression memberInitExpression2: + var newExpression = (NewExpression)MatchShaperNullabilityForSetOperation( + memberInitExpression1.NewExpression, memberInitExpression2.NewExpression, makeNullable); + + var memberBindings = new MemberBinding[memberInitExpression1.Bindings.Count]; + for (var i = 0; i < memberBindings.Length; i++) + { + var memberAssignment = memberInitExpression1.Bindings[i] as MemberAssignment; + Check.DebugAssert(memberAssignment != null, "Only member assignment bindings are supported"); - inMemoryQueryExpression1.UpdateServerQueryExpression( - Expression.Call( - setOperationMethodInfo.MakeGenericMethod(typeof(ValueBuffer)), - inMemoryQueryExpression1.ServerQueryExpression, - inMemoryQueryExpression2.ServerQueryExpression)); - return source1; + memberBindings[i] = memberAssignment.Update(MatchShaperNullabilityForSetOperation( + memberAssignment.Expression, ((MemberAssignment)memberInitExpression2.Bindings[i]).Expression, makeNullable)); + } + + return memberInitExpression1.Update(newExpression, memberBindings); + + default: + return shaper1; + } } } } diff --git a/src/EFCore.Relational/Query/RelationalEntityShaperExpression.cs b/src/EFCore.Relational/Query/RelationalEntityShaperExpression.cs index 9e4354f33e3..9c3b7a9615e 100644 --- a/src/EFCore.Relational/Query/RelationalEntityShaperExpression.cs +++ b/src/EFCore.Relational/Query/RelationalEntityShaperExpression.cs @@ -160,11 +160,18 @@ public override EntityShaperExpression WithEntityType(IEntityType entityType) } /// + [Obsolete("Use MakeNullable() instead.")] public override EntityShaperExpression MarkAsNullable() - => !IsNullable + => MakeNullable(); + + /// + public override EntityShaperExpression MakeNullable(bool nullable = true) + { + return IsNullable != nullable // Marking nullable requires recomputation of Discriminator condition ? new RelationalEntityShaperExpression(EntityType, ValueBufferExpression, true) : this; + } /// public override EntityShaperExpression Update(Expression valueBufferExpression) diff --git a/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs index e805fa50c92..ef0c530eaae 100644 --- a/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs @@ -298,7 +298,8 @@ private static ShapedQueryExpression CreateShapedQueryExpression(IEntityType ent ((SelectExpression)source1.QueryExpression).ApplyUnion((SelectExpression)source2.QueryExpression, distinct: false); - return source1; + return source1.UpdateShaperExpression( + MatchShaperNullabilityForSetOperation(source1.ShaperExpression, source2.ShaperExpression, makeNullable: true)); } /// @@ -410,6 +411,8 @@ private static ShapedQueryExpression CreateShapedQueryExpression(IEntityType ent Check.NotNull(source2, nameof(source2)); ((SelectExpression)source1.QueryExpression).ApplyExcept((SelectExpression)source2.QueryExpression, distinct: true); + + // Since except has result from source1, we don't need to change shaper return source1; } @@ -588,7 +591,9 @@ private static ShapedQueryExpression CreateShapedQueryExpression(IEntityType ent ((SelectExpression)source1.QueryExpression).ApplyIntersect((SelectExpression)source2.QueryExpression, distinct: true); - return source1; + // For intersect since result comes from both sides, if one of them is non-nullable then both are non-nullable + return source1.UpdateShaperExpression( + MatchShaperNullabilityForSetOperation(source1.ShaperExpression, source2.ShaperExpression, makeNullable: false)); } /// @@ -1195,7 +1200,9 @@ protected override Expression VisitMethodCall(MethodCallExpression methodCallExp Check.NotNull(source2, nameof(source2)); ((SelectExpression)source1.QueryExpression).ApplyUnion((SelectExpression)source2.QueryExpression, distinct: true); - return source1; + + return source1.UpdateShaperExpression( + MatchShaperNullabilityForSetOperation(source1.ShaperExpression, source2.ShaperExpression, makeNullable: true)); } /// @@ -1602,6 +1609,50 @@ private static void HandleGroupByForAggregate(SelectExpression selectExpression, } } + private Expression MatchShaperNullabilityForSetOperation(Expression shaper1, Expression shaper2, bool makeNullable) + { + switch (shaper1) + { + case EntityShaperExpression entityShaperExpression1 + when shaper2 is EntityShaperExpression entityShaperExpression2: + return entityShaperExpression1.IsNullable != entityShaperExpression2.IsNullable + ? entityShaperExpression1.MakeNullable(makeNullable) + : entityShaperExpression1; + + case NewExpression newExpression1 + when shaper2 is NewExpression newExpression2: + var newArguments = new Expression[newExpression1.Arguments.Count]; + for (var i = 0; i < newArguments.Length; i++) + { + newArguments[i] = MatchShaperNullabilityForSetOperation( + newExpression1.Arguments[i], newExpression2.Arguments[i], makeNullable); + } + + return newExpression1.Update(newArguments); + + case MemberInitExpression memberInitExpression1 + when shaper2 is MemberInitExpression memberInitExpression2: + var newExpression = (NewExpression)MatchShaperNullabilityForSetOperation( + memberInitExpression1.NewExpression, memberInitExpression2.NewExpression, makeNullable); + + var memberBindings = new MemberBinding[memberInitExpression1.Bindings.Count]; + for (var i = 0; i < memberBindings.Length; i++) + { + var memberAssignment = memberInitExpression1.Bindings[i] as MemberAssignment; + Check.DebugAssert(memberAssignment != null, "Only member assignment bindings are supported"); + + + memberBindings[i] = memberAssignment.Update(MatchShaperNullabilityForSetOperation( + memberAssignment.Expression, ((MemberAssignment)memberInitExpression2.Bindings[i]).Expression, makeNullable)); + } + + return memberInitExpression1.Update(newExpression, memberBindings); + + default: + return shaper1; + } + } + private ShapedQueryExpression? AggregateResultShaper( ShapedQueryExpression source, Expression? projection, diff --git a/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs b/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs index 9885c5346e3..02adb5a7a42 100644 --- a/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs +++ b/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs @@ -1662,7 +1662,7 @@ protected override Expression VisitExtension(Expression extensionExpression) Check.NotNull(extensionExpression, nameof(extensionExpression)); return extensionExpression is EntityShaperExpression entityShaper - ? entityShaper.MarkAsNullable() + ? entityShaper.MakeNullable() : base.VisitExtension(extensionExpression); } } diff --git a/src/EFCore/Query/EntityShaperExpression.cs b/src/EFCore/Query/EntityShaperExpression.cs index 8bca1cc2074..1e6c15fee54 100644 --- a/src/EFCore/Query/EntityShaperExpression.cs +++ b/src/EFCore/Query/EntityShaperExpression.cs @@ -229,10 +229,19 @@ public virtual EntityShaperExpression WithEntityType([NotNull] IEntityType entit /// Marks this shaper as nullable, indicating that it can shape null entity instances. /// /// This expression if nullability not changed, or an expression with updated nullability. + [Obsolete("Use MakeNullable() instead.")] public virtual EntityShaperExpression MarkAsNullable() - => !IsNullable + => MakeNullable(); + + /// + /// Assigns nullability for this shaper, indicating whether it can shape null entity instances or not. + /// + /// A value indicating if the shaper is nullable. + /// This expression if nullability not changed, or an expression with updated nullability. + public virtual EntityShaperExpression MakeNullable(bool nullable = true) + => IsNullable != nullable // Marking nullable requires recomputation of materialization condition - ? new EntityShaperExpression(EntityType, ValueBufferExpression, true) + ? new EntityShaperExpression(EntityType, ValueBufferExpression, nullable) : this; /// diff --git a/src/EFCore/Query/QueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore/Query/QueryableMethodTranslatingExpressionVisitor.cs index f51fa869eb5..dbc1413aef7 100644 --- a/src/EFCore/Query/QueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore/Query/QueryableMethodTranslatingExpressionVisitor.cs @@ -506,7 +506,7 @@ protected override Expression VisitExtension(Expression extensionExpression) Check.NotNull(extensionExpression, nameof(extensionExpression)); return extensionExpression is EntityShaperExpression entityShaper - ? entityShaper.MarkAsNullable() + ? entityShaper.MakeNullable() : base.VisitExtension(extensionExpression); } } diff --git a/test/EFCore.InMemory.FunctionalTests/Query/QueryBugsInMemoryTest.cs b/test/EFCore.InMemory.FunctionalTests/Query/QueryBugsInMemoryTest.cs index 157c8cee064..6a960ba7f66 100644 --- a/test/EFCore.InMemory.FunctionalTests/Query/QueryBugsInMemoryTest.cs +++ b/test/EFCore.InMemory.FunctionalTests/Query/QueryBugsInMemoryTest.cs @@ -1035,6 +1035,241 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) } } + #region Issue19253 + + [ConditionalFact] + public virtual void Concat_combines_nullability_of_entity_shapers() + { + using (CreateScratch(Seed19253, "19253")) + { + using var context = new MyContext19253(); + + Expression> leftKeySelector = x => x.forkey; + Expression> rightKeySelector = y => y.forkey; + + var query = context.A.GroupJoin( + context.B, + leftKeySelector, + rightKeySelector, + (left, rightg) => new + { + left, + rightg + }) + .SelectMany( + r => r.rightg.DefaultIfEmpty(), + (x, y) => new JoinResult19253 + { + Left = x.left, + Right = y + }) + .Concat( + context.B.GroupJoin( + context.A, + rightKeySelector, + leftKeySelector, + (right, leftg) => new { leftg, right }) + .SelectMany(l => l.leftg.DefaultIfEmpty(), + (x, y) => new JoinResult19253 + { + Left = y, + Right = x.right + }) + .Where(z => z.Left.Equals(null))) + .ToList(); + + Assert.Equal(3, query.Count); + } + } + + [ConditionalFact] + public virtual void Union_combines_nullability_of_entity_shapers() + { + using (CreateScratch(Seed19253, "19253")) + { + using var context = new MyContext19253(); + + Expression> leftKeySelector = x => x.forkey; + Expression> rightKeySelector = y => y.forkey; + + var query = context.A.GroupJoin( + context.B, + leftKeySelector, + rightKeySelector, + (left, rightg) => new + { + left, + rightg + }) + .SelectMany( + r => r.rightg.DefaultIfEmpty(), + (x, y) => new JoinResult19253 + { + Left = x.left, + Right = y + }) + .Union( + context.B.GroupJoin( + context.A, + rightKeySelector, + leftKeySelector, + (right, leftg) => new { leftg, right }) + .SelectMany(l => l.leftg.DefaultIfEmpty(), + (x, y) => new JoinResult19253 + { + Left = y, + Right = x.right + }) + .Where(z => z.Left.Equals(null))) + .ToList(); + + Assert.Equal(3, query.Count); + } + } + [ConditionalFact] + public virtual void Except_combines_nullability_of_entity_shapers() + { + using (CreateScratch(Seed19253, "19253")) + { + using var context = new MyContext19253(); + + Expression> leftKeySelector = x => x.forkey; + Expression> rightKeySelector = y => y.forkey; + + var query = context.A.GroupJoin( + context.B, + leftKeySelector, + rightKeySelector, + (left, rightg) => new + { + left, + rightg + }) + .SelectMany( + r => r.rightg.DefaultIfEmpty(), + (x, y) => new JoinResult19253 + { + Left = x.left, + Right = y + }) + .Except( + context.B.GroupJoin( + context.A, + rightKeySelector, + leftKeySelector, + (right, leftg) => new { leftg, right }) + .SelectMany(l => l.leftg.DefaultIfEmpty(), + (x, y) => new JoinResult19253 + { + Left = y, + Right = x.right + })) + .ToList(); + + Assert.Single(query); + } + } + [ConditionalFact] + public virtual void Intersect_combines_nullability_of_entity_shapers() + { + using (CreateScratch(Seed19253, "19253")) + { + using var context = new MyContext19253(); + + Expression> leftKeySelector = x => x.forkey; + Expression> rightKeySelector = y => y.forkey; + + var query = context.A.GroupJoin( + context.B, + leftKeySelector, + rightKeySelector, + (left, rightg) => new + { + left, + rightg + }) + .SelectMany( + r => r.rightg.DefaultIfEmpty(), + (x, y) => new JoinResult19253 + { + Left = x.left, + Right = y + }) + .Intersect( + context.B.GroupJoin( + context.A, + rightKeySelector, + leftKeySelector, + (right, leftg) => new { leftg, right }) + .SelectMany(l => l.leftg.DefaultIfEmpty(), + (x, y) => new JoinResult19253 + { + Left = y, + Right = x.right + })) + .ToList(); + + Assert.Single(query); + } + } + + public class MyContext19253 : DbContext + { + public DbSet A { get; set; } + public DbSet B { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder + .UseInternalServiceProvider(InMemoryFixture.DefaultServiceProvider) + .UseInMemoryDatabase("19253"); + } + } + + public class JoinResult19253 + { + public TLeft Left { get; set; } + + public TRight Right { get; set; } + } + + public class A19253 + { + public int Id { get; set; } + public string a { get; set; } + public string a1 { get; set; } + public string forkey { get; set; } + + } + + public class B19253 + { + public int Id { get; set; } + public string b { get; set; } + public string b1 { get; set; } + public string forkey { get; set; } + } + + private static void Seed19253(MyContext19253 context) + { + var tmp_a = new A19253[] + { + new A19253 {a = "a0", a1 = "a1", forkey = "a"}, + new A19253 {a = "a2", a1 = "a1", forkey = "d"}, + }; + var tmp_b = new B19253[] + { + new B19253 {b = "b0", b1 = "b1", forkey = "a"}, + new B19253 {b = "b2", b1 = "b1", forkey = "c"}, + }; + context.A.AddRange(tmp_a); + context.B.AddRange(tmp_b); + + context.SaveChanges(); + } + + #endregion + #endregion #region Issue23285 diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/QueryBugsTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/QueryBugsTest.cs index ae0bb62f942..f458b918601 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/QueryBugsTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/QueryBugsTest.cs @@ -9562,6 +9562,281 @@ public MyContext23282(DbContextOptions options) #endregion + #region Issue19253 + + [ConditionalFact] + public virtual void Concat_combines_nullability_of_entity_shapers() + { + using (CreateDatabase19253()) + { + using var context = new MyContext19253(_options); + + Expression> leftKeySelector = x => x.forkey; + Expression> rightKeySelector = y => y.forkey; + + var query = context.A.GroupJoin( + context.B, + leftKeySelector, + rightKeySelector, + (left, rightg) => new + { + left, + rightg + }) + .SelectMany( + r => r.rightg.DefaultIfEmpty(), + (x, y) => new JoinResult19253 + { + Left = x.left, + Right = y + }) + .Concat( + context.B.GroupJoin( + context.A, + rightKeySelector, + leftKeySelector, + (right, leftg) => new { leftg, right }) + .SelectMany(l => l.leftg.DefaultIfEmpty(), + (x, y) => new JoinResult19253 + { + Left = y, + Right = x.right + }) + .Where(z => z.Left.Equals(null))) + .ToList(); + + Assert.Equal(3, query.Count); + + AssertSql( + @"SELECT [a].[Id], [a].[a], [a].[a1], [a].[forkey], [b].[Id] AS [Id0], [b].[b], [b].[b1], [b].[forkey] AS [forkey0] +FROM [A] AS [a] +LEFT JOIN [B] AS [b] ON [a].[forkey] = [b].[forkey] +UNION ALL +SELECT [a0].[Id], [a0].[a], [a0].[a1], [a0].[forkey], [b0].[Id] AS [Id0], [b0].[b], [b0].[b1], [b0].[forkey] AS [forkey0] +FROM [B] AS [b0] +LEFT JOIN [A] AS [a0] ON [b0].[forkey] = [a0].[forkey] +WHERE [a0].[Id] IS NULL"); + } + } + + [ConditionalFact] + public virtual void Union_combines_nullability_of_entity_shapers() + { + using (CreateDatabase19253()) + { + using var context = new MyContext19253(_options); + + Expression> leftKeySelector = x => x.forkey; + Expression> rightKeySelector = y => y.forkey; + + var query = context.A.GroupJoin( + context.B, + leftKeySelector, + rightKeySelector, + (left, rightg) => new + { + left, + rightg + }) + .SelectMany( + r => r.rightg.DefaultIfEmpty(), + (x, y) => new JoinResult19253 + { + Left = x.left, + Right = y + }) + .Union( + context.B.GroupJoin( + context.A, + rightKeySelector, + leftKeySelector, + (right, leftg) => new { leftg, right }) + .SelectMany(l => l.leftg.DefaultIfEmpty(), + (x, y) => new JoinResult19253 + { + Left = y, + Right = x.right + }) + .Where(z => z.Left.Equals(null))) + .ToList(); + + Assert.Equal(3, query.Count); + + AssertSql( + @"SELECT [a].[Id], [a].[a], [a].[a1], [a].[forkey], [b].[Id] AS [Id0], [b].[b], [b].[b1], [b].[forkey] AS [forkey0] +FROM [A] AS [a] +LEFT JOIN [B] AS [b] ON [a].[forkey] = [b].[forkey] +UNION +SELECT [a0].[Id], [a0].[a], [a0].[a1], [a0].[forkey], [b0].[Id] AS [Id0], [b0].[b], [b0].[b1], [b0].[forkey] AS [forkey0] +FROM [B] AS [b0] +LEFT JOIN [A] AS [a0] ON [b0].[forkey] = [a0].[forkey] +WHERE [a0].[Id] IS NULL"); + } + } + [ConditionalFact] + public virtual void Except_combines_nullability_of_entity_shapers() + { + using (CreateDatabase19253()) + { + using var context = new MyContext19253(_options); + + Expression> leftKeySelector = x => x.forkey; + Expression> rightKeySelector = y => y.forkey; + + var query = context.A.GroupJoin( + context.B, + leftKeySelector, + rightKeySelector, + (left, rightg) => new + { + left, + rightg + }) + .SelectMany( + r => r.rightg.DefaultIfEmpty(), + (x, y) => new JoinResult19253 + { + Left = x.left, + Right = y + }) + .Except( + context.B.GroupJoin( + context.A, + rightKeySelector, + leftKeySelector, + (right, leftg) => new { leftg, right }) + .SelectMany(l => l.leftg.DefaultIfEmpty(), + (x, y) => new JoinResult19253 + { + Left = y, + Right = x.right + })) + .ToList(); + + Assert.Single(query); + + AssertSql( + @"SELECT [a].[Id], [a].[a], [a].[a1], [a].[forkey], [b].[Id] AS [Id0], [b].[b], [b].[b1], [b].[forkey] AS [forkey0] +FROM [A] AS [a] +LEFT JOIN [B] AS [b] ON [a].[forkey] = [b].[forkey] +EXCEPT +SELECT [a0].[Id], [a0].[a], [a0].[a1], [a0].[forkey], [b0].[Id] AS [Id0], [b0].[b], [b0].[b1], [b0].[forkey] AS [forkey0] +FROM [B] AS [b0] +LEFT JOIN [A] AS [a0] ON [b0].[forkey] = [a0].[forkey]"); + } + } + [ConditionalFact] + public virtual void Intersect_combines_nullability_of_entity_shapers() + { + using (CreateDatabase19253()) + { + using var context = new MyContext19253(_options); + + Expression> leftKeySelector = x => x.forkey; + Expression> rightKeySelector = y => y.forkey; + + var query = context.A.GroupJoin( + context.B, + leftKeySelector, + rightKeySelector, + (left, rightg) => new + { + left, + rightg + }) + .SelectMany( + r => r.rightg.DefaultIfEmpty(), + (x, y) => new JoinResult19253 + { + Left = x.left, + Right = y + }) + .Intersect( + context.B.GroupJoin( + context.A, + rightKeySelector, + leftKeySelector, + (right, leftg) => new { leftg, right }) + .SelectMany(l => l.leftg.DefaultIfEmpty(), + (x, y) => new JoinResult19253 + { + Left = y, + Right = x.right + })) + .ToList(); + + Assert.Single(query); + + AssertSql( + @"SELECT [a].[Id], [a].[a], [a].[a1], [a].[forkey], [b].[Id] AS [Id0], [b].[b], [b].[b1], [b].[forkey] AS [forkey0] +FROM [A] AS [a] +LEFT JOIN [B] AS [b] ON [a].[forkey] = [b].[forkey] +INTERSECT +SELECT [a0].[Id], [a0].[a], [a0].[a1], [a0].[forkey], [b0].[Id] AS [Id0], [b0].[b], [b0].[b1], [b0].[forkey] AS [forkey0] +FROM [B] AS [b0] +LEFT JOIN [A] AS [a0] ON [b0].[forkey] = [a0].[forkey]"); + } + } + + public class MyContext19253 : DbContext + { + public DbSet A { get; set; } + public DbSet B { get; set; } + + + public MyContext19253(DbContextOptions options) + : base(options) + { + } + } + + public class JoinResult19253 + { + public TLeft Left { get; set; } + + public TRight Right { get; set; } + } + + public class A19253 + { + public int Id { get; set; } + public string a { get; set; } + public string a1 { get; set; } + public string forkey { get; set; } + + } + + public class B19253 + { + public int Id { get; set; } + public string b { get; set; } + public string b1 { get; set; } + public string forkey { get; set; } + } + + private SqlServerTestStore CreateDatabase19253() + => CreateTestStore( + () => new MyContext19253(_options), + context => + { + var tmp_a = new A19253[] + { + new A19253 {a = "a0", a1 = "a1", forkey = "a"}, + new A19253 {a = "a2", a1 = "a1", forkey = "d"}, + }; + var tmp_b = new B19253[] + { + new B19253 {b = "b0", b1 = "b1", forkey = "a"}, + new B19253 {b = "b2", b1 = "b1", forkey = "c"}, + }; + context.A.AddRange(tmp_a); + context.B.AddRange(tmp_b); + context.SaveChanges(); + ClearLog(); + }); + + #endregion + private DbContextOptions _options; private SqlServerTestStore CreateTestStore(