From fad532d0cef5897427292f4b3632025cfcb37258 Mon Sep 17 00:00:00 2001 From: Smit Patel Date: Fri, 6 Nov 2020 15:41:30 -0800 Subject: [PATCH] Query: Assign nullability to entity shaper correctly for set operations 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 b0656bb5bd3..c61630a44ca 100644 --- a/src/EFCore.InMemory/Query/Internal/InMemoryQueryExpression.cs +++ b/src/EFCore.InMemory/Query/Internal/InMemoryQueryExpression.cs @@ -377,6 +377,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 ac691d0d4da..622d5356cb0 100644 --- a/src/EFCore.InMemory/Query/Internal/InMemoryQueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore.InMemory/Query/Internal/InMemoryQueryableMethodTranslatingExpressionVisitor.cs @@ -1570,18 +1570,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 5cf720ed993..1a5ed5a8775 100644 --- a/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs @@ -297,7 +297,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)); } /// @@ -409,6 +410,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; } @@ -576,7 +579,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)); } /// @@ -1183,7 +1188,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)); } /// @@ -1573,6 +1580,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 092d1746d64..b35d4b6f3d7 100644 --- a/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs +++ b/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs @@ -1663,7 +1663,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 09ac90e8c40..0082ba0b927 100644 --- a/src/EFCore/Query/EntityShaperExpression.cs +++ b/src/EFCore/Query/EntityShaperExpression.cs @@ -230,10 +230,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 de17d177875..0e1f5b86fad 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 b837e4e168b..443c9fa0be6 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 SharedHelper diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/QueryBugsTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/QueryBugsTest.cs index 5773438b091..a1256434f2f 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/QueryBugsTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/QueryBugsTest.cs @@ -9322,6 +9322,281 @@ private SqlServerTestStore CreateDatabase10295() #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(