Skip to content

Commit 8695aef

Browse files
authored
[release/10.0] Fix issues with nulls in primitive collections (#38066)
Backport of #37674.
1 parent 74afc72 commit 8695aef

9 files changed

Lines changed: 124 additions & 2 deletions

File tree

src/EFCore.Relational/Query/SqlNullabilityProcessor.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ public class SqlNullabilityProcessor : ExpressionVisitor
2626
private static readonly bool UseOldBehavior37152 =
2727
AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue37152", out var enabled) && enabled;
2828

29+
private static readonly bool UseOldBehavior37537 =
30+
AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue37537", out var enabled) && enabled;
31+
2932
private readonly List<ColumnExpression> _nonNullableColumns;
3033
private readonly List<ColumnExpression> _nullValueColumns;
3134
private readonly ISqlExpressionFactory _sqlExpressionFactory;
@@ -1906,13 +1909,21 @@ protected virtual bool TryMakeNonNullable(
19061909
var parameters = ParametersDecorator.GetAndDisableCaching();
19071910

19081911
IList values;
1912+
Type elementClrType;
19091913
if (UseOldBehavior37204)
19101914
{
19111915
if (parameters[collectionParameter.Name] is not IList list)
19121916
{
19131917
throw new UnreachableException($"Parameter '{collectionParameter.Name}' is not an IList.");
19141918
}
19151919
values = list;
1920+
elementClrType = UseOldBehavior37537
1921+
? values.GetType().GetSequenceType()
1922+
// We found the first null value - we need to start copying values to a new list which will be used for the rewritten parameter.
1923+
// The type of the new list must match that of the original enumerable parameter, as there may be value converters involved which
1924+
// rely on the precise element type (see #37605). We therefore get the type of the element from the original list if it implements
1925+
// IEnumerable<T>, or default to object.
1926+
: list.GetType().TryGetElementType(typeof(IEnumerable<>)) ?? typeof(object);
19161927
}
19171928
else
19181929
{
@@ -1921,6 +1932,13 @@ protected virtual bool TryMakeNonNullable(
19211932
throw new UnreachableException($"Parameter '{collectionParameter.Name}' is not an IEnumerable.");
19221933
}
19231934
values = enumerable.Cast<object?>().ToList();
1935+
elementClrType = UseOldBehavior37537
1936+
? values.GetType().GetSequenceType()
1937+
// We found the first null value - we need to start copying values to a new list which will be used for the rewritten parameter.
1938+
// The type of the new list must match that of the original enumerable parameter, as there may be value converters involved which
1939+
// rely on the precise element type (see #37605). We therefore get the type of the element from the original list if it implements
1940+
// IEnumerable<T>, or default to object.
1941+
: enumerable.GetType().TryGetElementType(typeof(IEnumerable<>)) ?? typeof(object);
19241942
}
19251943

19261944
IList? processedValues = null;
@@ -1933,7 +1951,6 @@ protected virtual bool TryMakeNonNullable(
19331951
{
19341952
if (processedValues is null)
19351953
{
1936-
var elementClrType = values.GetType().GetSequenceType();
19371954
processedValues = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(elementClrType), values.Count)!;
19381955
for (var j = 0; j < i; j++)
19391956
{

src/EFCore.SqlServer/Query/Internal/SqlServerSqlNullabilityProcessor.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ public class SqlServerSqlNullabilityProcessor : SqlNullabilityProcessor
2727
private static readonly bool UseOldBehavior37336 =
2828
AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue37336", out var enabled) && enabled;
2929

30+
private static readonly bool UseOldBehavior37537 =
31+
AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue37537", out var enabled) && enabled;
32+
3033
/// <summary>
3134
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
3235
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
@@ -290,7 +293,9 @@ protected override SqlExpression VisitIn(InExpression inExpression, bool allowOp
290293
new ColumnExpression(
291294
columnName,
292295
openJson.Alias,
293-
valuesParameter.Type.GetSequenceType(),
296+
UseOldBehavior37537
297+
? valuesParameter.Type.GetSequenceType()
298+
: valuesParameter.Type.GetSequenceType().UnwrapNullableType(),
294299
elementTypeMapping,
295300
containsNulls!.Value),
296301
columnName)

test/EFCore.Cosmos.FunctionalTests/Query/PrimitiveCollectionsQueryCosmosTest.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,20 @@ WHERE NOT(ARRAY_CONTAINS(@nullableInts, c["NullableInt"]))
647647
""");
648648
}
649649

650+
public override async Task Parameter_collection_of_nullable_ints_Contains_nullable_int_with_EF_Parameter()
651+
{
652+
await base.Parameter_collection_of_nullable_ints_Contains_nullable_int_with_EF_Parameter();
653+
654+
AssertSql(
655+
"""
656+
@nullableInts='[null,999]'
657+
658+
SELECT VALUE c
659+
FROM root c
660+
WHERE ARRAY_CONTAINS(@nullableInts, c["NullableInt"])
661+
""");
662+
}
663+
650664
public override async Task Parameter_collection_of_strings_Contains_string()
651665
{
652666
await base.Parameter_collection_of_strings_Contains_string();

test/EFCore.Specification.Tests/Query/PrimitiveCollectionsQueryTestBase.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,16 @@ public virtual async Task Parameter_collection_of_nullable_ints_Contains_nullabl
308308
await AssertQuery(ss => ss.Set<PrimitiveCollectionsEntity>().Where(c => !nullableInts.Contains(c.NullableInt)));
309309
}
310310

311+
[ConditionalFact] // #37605
312+
public virtual async Task Parameter_collection_of_nullable_ints_Contains_nullable_int_with_EF_Parameter()
313+
{
314+
var nullableInts = new int?[] { null, 999 };
315+
316+
await AssertQuery(
317+
ss => ss.Set<PrimitiveCollectionsEntity>().Where(c => EF.Parameter(nullableInts).Contains(c.NullableInt)),
318+
ss => ss.Set<PrimitiveCollectionsEntity>().Where(c => nullableInts.Contains(c.NullableInt)));
319+
}
320+
311321
[ConditionalFact]
312322
public virtual async Task Parameter_collection_of_structs_Contains_struct()
313323
{

test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQueryOldSqlServerTest.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,14 @@ WHERE [p].[NullableInt] IS NOT NULL AND [p].[NullableInt] <> @nullableInts1
623623
""");
624624
}
625625

626+
public override async Task Parameter_collection_of_nullable_ints_Contains_nullable_int_with_EF_Parameter()
627+
{
628+
// EF.Parameter() on primitive collection (OPENJSON on SQL Server) not supported on old versions of SQL Server.
629+
await Assert.ThrowsAsync<InvalidOperationException>(base.Parameter_collection_of_nullable_ints_Contains_nullable_int_with_EF_Parameter);
630+
631+
AssertSql();
632+
}
633+
626634
public override async Task Parameter_collection_of_strings_Contains_string()
627635
{
628636
await base.Parameter_collection_of_strings_Contains_string();

test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQuerySqlServer160Test.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,23 @@ WHERE [p].[NullableInt] IS NOT NULL AND [p].[NullableInt] <> @nullableInts1
631631
""");
632632
}
633633

634+
public override async Task Parameter_collection_of_nullable_ints_Contains_nullable_int_with_EF_Parameter()
635+
{
636+
await base.Parameter_collection_of_nullable_ints_Contains_nullable_int_with_EF_Parameter();
637+
638+
AssertSql(
639+
"""
640+
@nullableInts_without_nulls='[999]' (Size = 4000)
641+
642+
SELECT [p].[Id], [p].[Bool], [p].[Bools], [p].[DateTime], [p].[DateTimes], [p].[Enum], [p].[Enums], [p].[Int], [p].[Ints], [p].[NullableInt], [p].[NullableInts], [p].[NullableString], [p].[NullableStrings], [p].[NullableWrappedId], [p].[NullableWrappedIdWithNullableComparer], [p].[String], [p].[Strings], [p].[WrappedId]
643+
FROM [PrimitiveCollectionsEntity] AS [p]
644+
WHERE [p].[NullableInt] IN (
645+
SELECT [n].[value]
646+
FROM OPENJSON(@nullableInts_without_nulls) AS [n]
647+
) OR [p].[NullableInt] IS NULL
648+
""");
649+
}
650+
634651
public override async Task Parameter_collection_of_strings_Contains_string()
635652
{
636653
await base.Parameter_collection_of_strings_Contains_string();

test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQuerySqlServerJsonTypeTest.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -800,6 +800,23 @@ WHERE [p].[NullableInt] IS NOT NULL AND [p].[NullableInt] <> @nullableInts1
800800
""");
801801
}
802802

803+
public override async Task Parameter_collection_of_nullable_ints_Contains_nullable_int_with_EF_Parameter()
804+
{
805+
await base.Parameter_collection_of_nullable_ints_Contains_nullable_int_with_EF_Parameter();
806+
807+
AssertSql(
808+
"""
809+
@nullableInts_without_nulls='[999]' (Size = 5)
810+
811+
SELECT [p].[Id], [p].[Bool], [p].[Bools], [p].[DateTime], [p].[DateTimes], [p].[Enum], [p].[Enums], [p].[Int], [p].[Ints], [p].[NullableInt], [p].[NullableInts], [p].[NullableString], [p].[NullableStrings], [p].[NullableWrappedId], [p].[NullableWrappedIdWithNullableComparer], [p].[String], [p].[Strings], [p].[WrappedId]
812+
FROM [PrimitiveCollectionsEntity] AS [p]
813+
WHERE [p].[NullableInt] IN (
814+
SELECT [n].[value]
815+
FROM OPENJSON(@nullableInts_without_nulls) AS [n]
816+
) OR [p].[NullableInt] IS NULL
817+
""");
818+
}
819+
803820
public override async Task Parameter_collection_of_structs_Contains_struct()
804821
{
805822
await base.Parameter_collection_of_structs_Contains_struct();

test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQuerySqlServerTest.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,23 @@ WHERE [p].[NullableInt] IS NOT NULL AND [p].[NullableInt] <> @nullableInts1
654654
""");
655655
}
656656

657+
public override async Task Parameter_collection_of_nullable_ints_Contains_nullable_int_with_EF_Parameter()
658+
{
659+
await base.Parameter_collection_of_nullable_ints_Contains_nullable_int_with_EF_Parameter();
660+
661+
AssertSql(
662+
"""
663+
@nullableInts_without_nulls='[999]' (Size = 4000)
664+
665+
SELECT [p].[Id], [p].[Bool], [p].[Bools], [p].[DateTime], [p].[DateTimes], [p].[Enum], [p].[Enums], [p].[Int], [p].[Ints], [p].[NullableInt], [p].[NullableInts], [p].[NullableString], [p].[NullableStrings], [p].[NullableWrappedId], [p].[NullableWrappedIdWithNullableComparer], [p].[String], [p].[Strings], [p].[WrappedId]
666+
FROM [PrimitiveCollectionsEntity] AS [p]
667+
WHERE [p].[NullableInt] IN (
668+
SELECT [n].[value]
669+
FROM OPENJSON(@nullableInts_without_nulls) AS [n]
670+
) OR [p].[NullableInt] IS NULL
671+
""");
672+
}
673+
657674
public override async Task Parameter_collection_of_strings_Contains_string()
658675
{
659676
await base.Parameter_collection_of_strings_Contains_string();

test/EFCore.Sqlite.FunctionalTests/Query/PrimitiveCollectionsQuerySqliteTest.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,23 @@ public override async Task Parameter_collection_of_nullable_ints_Contains_nullab
642642
""");
643643
}
644644

645+
public override async Task Parameter_collection_of_nullable_ints_Contains_nullable_int_with_EF_Parameter()
646+
{
647+
await base.Parameter_collection_of_nullable_ints_Contains_nullable_int_with_EF_Parameter();
648+
649+
AssertSql(
650+
"""
651+
@nullableInts_without_nulls='[999]' (Size = 5)
652+
653+
SELECT "p"."Id", "p"."Bool", "p"."Bools", "p"."DateTime", "p"."DateTimes", "p"."Enum", "p"."Enums", "p"."Int", "p"."Ints", "p"."NullableInt", "p"."NullableInts", "p"."NullableString", "p"."NullableStrings", "p"."NullableWrappedId", "p"."NullableWrappedIdWithNullableComparer", "p"."String", "p"."Strings", "p"."WrappedId"
654+
FROM "PrimitiveCollectionsEntity" AS "p"
655+
WHERE "p"."NullableInt" IN (
656+
SELECT "n"."value"
657+
FROM json_each(@nullableInts_without_nulls) AS "n"
658+
) OR "p"."NullableInt" IS NULL
659+
""");
660+
}
661+
645662
public override async Task Parameter_collection_of_strings_Contains_string()
646663
{
647664
await base.Parameter_collection_of_strings_Contains_string();

0 commit comments

Comments
 (0)