From 2ca6cb29bc86d512875731038b74dd82ac503752 Mon Sep 17 00:00:00 2001 From: Sanjay Date: Wed, 14 Oct 2026 17:15:44 -0400 Subject: [PATCH 1/3] Cast object collection elements through the value converter so Contains can compile. An object[] or List was passed straight into an int converter, so the query failed to compile. --- .../Mapping/NpgsqlArrayTypeMapping.cs | 7 +- .../ValueConversion/NpgsqlArrayConverter.cs | 7 +- .../ObjectCollectionContainsNpgsqlTest.cs | 85 +++++++++++++++++++ .../Storage/NpgsqlArrayValueConverterTest.cs | 39 +++++++++ 4 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 test/EFCore.PG.FunctionalTests/Query/ObjectCollectionContainsNpgsqlTest.cs diff --git a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayTypeMapping.cs b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayTypeMapping.cs index 749e3d3a0..08bdab881 100644 --- a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayTypeMapping.cs +++ b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayTypeMapping.cs @@ -138,14 +138,17 @@ private static RelationalTypeMappingParameters CreateParameters(string storeType : elementType.IsValueType ? typeof(ListOfValueTypesComparer<,>).MakeGenericType(typeof(TConcreteCollection), elementType) : typeof(ListOfReferenceTypesComparer<,>).MakeGenericType(typeof(TConcreteCollection), elementType), - elementMapping.Comparer.ToNullableComparer(elementType)!); + // ComposeConversion covers nullable value types and a wider element type such as object[] over int. + elementMapping.Comparer.ComposeConversion(elementType)!); #pragma warning restore EF1001 var elementJsonReaderWriter = elementMapping.JsonValueReaderWriter; if (elementJsonReaderWriter is not null) { - if (!typeof(TElement).UnwrapNullableType().IsAssignableTo(elementJsonReaderWriter.ValueType)) + // object[] is a valid wider element type over a converted value (for example int stored as bigint). + if (typeof(TElement).UnwrapNullableType() != typeof(object) + && !typeof(TElement).UnwrapNullableType().IsAssignableTo(elementJsonReaderWriter.ValueType)) { throw new InvalidOperationException( $"When building an array mapping over '{typeof(TElement).Name}', the JsonValueReaderWriter for element mapping '{elementMapping.GetType().Name}' is incorrect ('{elementJsonReaderWriter.ValueType.Name}' instead of '{typeof(TElement).UnwrapNullableType()}', the JsonValueReaderWriter is '{elementJsonReaderWriter.GetType().Name}')."); diff --git a/src/EFCore.PG/Storage/ValueConversion/NpgsqlArrayConverter.cs b/src/EFCore.PG/Storage/ValueConversion/NpgsqlArrayConverter.cs index 3f9360e25..91682739e 100644 --- a/src/EFCore.PG/Storage/ValueConversion/NpgsqlArrayConverter.cs +++ b/src/EFCore.PG/Storage/ValueConversion/NpgsqlArrayConverter.cs @@ -46,7 +46,7 @@ public NpgsqlArrayConverter(ValueConverter? elementConverter) if (elementConverter is not null) { - if (modelElementType.UnwrapNullableType() != elementConverter.ModelClrType.UnwrapNullableType()) + if (!ElementTypeMatches(modelElementType, elementConverter.ModelClrType)) { throw new ArgumentException( $"The element's value converter model type ({elementConverter.ModelClrType}), doesn't match the array's ({modelElementType})"); @@ -308,6 +308,11 @@ elementConversionExpression is null return Lambda>(body, input); } + // object is a valid wider element type (e.g. object[] with an int converter). Other mismatches stay rejected. + private static bool ElementTypeMatches(Type collectionElementType, Type converterClrType) + => collectionElementType.UnwrapNullableType() == converterClrType.UnwrapNullableType() + || collectionElementType.UnwrapNullableType() == typeof(object); + private static Expression ForLoop( ParameterExpression loopVar, Expression initValue, diff --git a/test/EFCore.PG.FunctionalTests/Query/ObjectCollectionContainsNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/ObjectCollectionContainsNpgsqlTest.cs new file mode 100644 index 000000000..2bf53f932 --- /dev/null +++ b/test/EFCore.PG.FunctionalTests/Query/ObjectCollectionContainsNpgsqlTest.cs @@ -0,0 +1,85 @@ +namespace Microsoft.EntityFrameworkCore.Query; + +public class ObjectCollectionContainsNpgsqlTest(NonSharedFixture fixture) : NonSharedModelTestBase(fixture), IClassFixture +{ + protected override string NonSharedStoreName + => "ObjectCollectionContainsTest"; + + protected override ITestStoreFactory NonSharedTestStoreFactory + => NpgsqlTestStoreFactory.Instance; + + [ConditionalFact] + public virtual async Task Contains_on_object_array_with_element_value_converter() + { + var contextFactory = await InitializeNonSharedTest(seed: SeedAsync); + + using var context = contextFactory.CreateDbContext(); + var ids = new object[] { 1, 2 }; + + var result = await context.Blogs.Where(b => ids.Contains(b.Id)).OrderBy(b => b.Id).Select(b => b.Id).ToListAsync(); + + Assert.Equal([1, 2], result); + + AssertSql( + """ +@ids={ '1' +'2' } (DbType = Object) + +SELECT b."Id" +FROM "Blogs" AS b +WHERE b."Id" = ANY (@ids) +ORDER BY b."Id" NULLS FIRST +"""); + } + + [ConditionalFact] + public virtual async Task Contains_on_object_list_with_element_value_converter() + { + var contextFactory = await InitializeNonSharedTest(seed: SeedAsync); + + using var context = contextFactory.CreateDbContext(); + var ids = new List { 1, 2 }; + + var result = await context.Blogs.Where(b => ids.Contains(b.Id)).OrderBy(b => b.Id).Select(b => b.Id).ToListAsync(); + + Assert.Equal([1, 2], result); + + AssertSql( + """ +@ids={ '1' +'2' } (DbType = Object) + +SELECT b."Id" +FROM "Blogs" AS b +WHERE b."Id" = ANY (@ids) +ORDER BY b."Id" NULLS FIRST +"""); + } + + private static async Task SeedAsync(BlogContext context) + { + context.Blogs.AddRange( + new Blog { Id = 1 }, + new Blog { Id = 2 }, + new Blog { Id = 3 }); + await context.SaveChangesAsync(); + } + + protected void AssertSql(params string[] expected) + => ((TestSqlLoggerFactory)ListLoggerFactory).AssertBaseline(expected); + + protected class BlogContext(DbContextOptions options) : DbContext(options) + { + public DbSet Blogs => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity().Property(b => b.Id) + .HasConversion(new ValueConverter(v => v, v => checked((int)v))) + .HasColumnType("bigint"); + } + + protected class Blog + { + public int Id { get; set; } + } +} diff --git a/test/EFCore.PG.Tests/Storage/NpgsqlArrayValueConverterTest.cs b/test/EFCore.PG.Tests/Storage/NpgsqlArrayValueConverterTest.cs index ed2b9d7e0..667759b73 100644 --- a/test/EFCore.PG.Tests/Storage/NpgsqlArrayValueConverterTest.cs +++ b/test/EFCore.PG.Tests/Storage/NpgsqlArrayValueConverterTest.cs @@ -83,6 +83,45 @@ public void Can_convert_non_nullable_lists_with_nullable_element_converter() Assert.Equal([1, 2], converter.ConvertFromProviderExpression.Compile()(["1", "2"])); } + [ConditionalFact] + public void Can_convert_object_arrays_through_an_element_converter() + { + var elementConverter = new ValueConverter(v => v, v => checked((int)v)); + var converter = new NpgsqlArrayConverter(elementConverter); + + var toProvider = (Func)converter.ConvertToProviderExpression.Compile(); + Assert.Equal([1L, 2L], toProvider([1, 2])); + Assert.Null(toProvider(null)); + + var fromProvider = (Func)converter.ConvertFromProviderExpression.Compile(); + Assert.Equal(new object[] { 1, 2 }, fromProvider([1L, 2L])); + Assert.Null(fromProvider(null)); + } + + [ConditionalFact] + public void Can_convert_object_lists_through_an_element_converter() + { + var elementConverter = new ValueConverter(v => v, v => checked((int)v)); + var converter = new NpgsqlArrayConverter, List, long[]>(elementConverter); + + var toProvider = (Func, long[]>)converter.ConvertToProviderExpression.Compile(); + Assert.Equal([1L, 2L], toProvider([1, 2])); + + var fromProvider = (Func>)converter.ConvertFromProviderExpression.Compile(); + Assert.Equal(new List { 1, 2 }, fromProvider([1L, 2L])); + } + + [ConditionalFact] + public void Rejects_an_element_converter_whose_type_does_not_match_the_array() + { + var elementConverter = new ValueConverter(v => v, v => checked((int)v)); + + var exception = Assert.Throws( + () => new NpgsqlArrayConverter(elementConverter)); + + Assert.Contains("doesn't match", exception.Message); + } + private enum Beatles { John = 7, From 68beb158236e2a77563906eefec5ce8c41b456de Mon Sep 17 00:00:00 2001 From: Sanjay Date: Thu, 15 Oct 2026 12:09:43 -0400 Subject: [PATCH 2/3] Preserve nulls when an object collection is converted through a value-type element converter. --- .../Mapping/NpgsqlArrayTypeMapping.cs | 15 ++- .../ObjectCollectionContainsNpgsqlTest.cs | 115 +++++++++++++++++- .../Storage/NpgsqlArrayValueConverterTest.cs | 62 ++++++++++ 3 files changed, 183 insertions(+), 9 deletions(-) diff --git a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayTypeMapping.cs b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayTypeMapping.cs index 08bdab881..275a24827 100644 --- a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayTypeMapping.cs +++ b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayTypeMapping.cs @@ -110,8 +110,10 @@ private static RelationalTypeMappingParameters CreateParameters(string storeType { var providerElementType = elementConverter.ProviderClrType; - // Nullability has been unwrapped on the element converter's provider CLR type, so add it back here if needed - if (elementType.IsNullableValueType()) + // Nullability has been unwrapped on the element converter's provider CLR type, so add it back here if needed. + // object can contain null, and a value-type provider element cannot store that null unless it is nullable. + if (elementType.IsNullableValueType() + || (elementType.UnwrapNullableType() == typeof(object) && providerElementType.IsValueType)) { providerElementType = providerElementType.MakeNullable(); } @@ -132,14 +134,17 @@ private static RelationalTypeMappingParameters CreateParameters(string storeType var comparer = typeof(TCollection).IsArray && typeof(TCollection).GetArrayRank() > 1 ? null // TODO: Value comparer for multidimensional arrays : (ValueComparer?)Activator.CreateInstance( - elementType.IsNullableValueType() || elementMapping.Comparer.Type.IsNullableValueType() + elementType.IsNullableValueType() + || (elementType.IsValueType && elementMapping.Comparer.Type.IsNullableValueType()) ? typeof(ListOfNullableValueTypesComparer<,>) .MakeGenericType(typeof(TConcreteCollection), elementType.UnwrapNullableType()) : elementType.IsValueType ? typeof(ListOfValueTypesComparer<,>).MakeGenericType(typeof(TConcreteCollection), elementType) : typeof(ListOfReferenceTypesComparer<,>).MakeGenericType(typeof(TConcreteCollection), elementType), - // ComposeConversion covers nullable value types and a wider element type such as object[] over int. - elementMapping.Comparer.ComposeConversion(elementType)!); + // ComposeConversion adapts a value comparer to object. Typed elements keep ToNullableComparer. + elementType.UnwrapNullableType() == typeof(object) + ? elementMapping.Comparer.ComposeConversion(elementType)! + : elementMapping.Comparer.ToNullableComparer(elementType)!); #pragma warning restore EF1001 var elementJsonReaderWriter = elementMapping.JsonValueReaderWriter; diff --git a/test/EFCore.PG.FunctionalTests/Query/ObjectCollectionContainsNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/ObjectCollectionContainsNpgsqlTest.cs index 2bf53f932..e0b0997bc 100644 --- a/test/EFCore.PG.FunctionalTests/Query/ObjectCollectionContainsNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/ObjectCollectionContainsNpgsqlTest.cs @@ -56,12 +56,109 @@ ORDER BY b."Id" NULLS FIRST """); } + [ConditionalFact] + public virtual async Task Contains_on_object_array_with_null_element() + { + var contextFactory = await InitializeNonSharedTest(seed: SeedAsync); + + using var context = contextFactory.CreateDbContext(); + var ids = new object[] { null!, 2 }; + + var result = await context.Blogs.Where(b => ids.Contains(b.Id)).OrderBy(b => b.Id).Select(b => b.Id).ToListAsync(); + + Assert.Equal([2], result); + + AssertSql( + """ +@ids={ NULL +'2' } (DbType = Object) + +SELECT b."Id" +FROM "Blogs" AS b +WHERE b."Id" = ANY (@ids) +ORDER BY b."Id" NULLS FIRST +"""); + } + + [ConditionalFact] + public virtual async Task Contains_on_object_list_with_null_element() + { + var contextFactory = await InitializeNonSharedTest(seed: SeedAsync); + + using var context = contextFactory.CreateDbContext(); + var ids = new List { null!, 2 }; + + var result = await context.Blogs.Where(b => ids.Contains(b.Id)).OrderBy(b => b.Id).Select(b => b.Id).ToListAsync(); + + Assert.Equal([2], result); + + AssertSql( + """ +@ids={ NULL +'2' } (DbType = Object) + +SELECT b."Id" +FROM "Blogs" AS b +WHERE b."Id" = ANY (@ids) +ORDER BY b."Id" NULLS FIRST +"""); + } + + [ConditionalFact] + public virtual async Task Contains_on_object_array_with_string_element_converter() + { + var contextFactory = await InitializeNonSharedTest(seed: SeedAsync); + + using var context = contextFactory.CreateDbContext(); + var labels = new object[] { 1, null!, 2 }; + + var result = await context.Blogs.Where(b => labels.Contains(b.Label)).OrderBy(b => b.Id).Select(b => b.Id).ToListAsync(); + + Assert.Equal([1, 2], result); + + AssertSql( + """ +@labels={ '1' +NULL +'2' } (DbType = Object) + +SELECT b."Id" +FROM "Blogs" AS b +WHERE b."Label" = ANY (@labels) +ORDER BY b."Id" NULLS FIRST +"""); + } + + [ConditionalFact] + public virtual async Task Contains_on_object_array_matches_null_on_nullable_converted_property() + { + var contextFactory = await InitializeNonSharedTest(seed: SeedAsync); + + using var context = contextFactory.CreateDbContext(); + var ids = new object[] { null!, 2 }; + + var result = await context.Blogs.Where(b => ids.Contains(b.NullableId)).OrderBy(b => b.Id).Select(b => b.Id).ToListAsync(); + + Assert.Equal([2, 3], result); + + AssertSql( + """ +@ids={ NULL +'2' } (DbType = Object) + +SELECT b."Id" +FROM "Blogs" AS b +WHERE b."NullableId" = ANY (@ids) OR (b."NullableId" IS NULL AND array_position(@ids, NULL) IS NOT NULL) +ORDER BY b."Id" NULLS FIRST +"""); + } + private static async Task SeedAsync(BlogContext context) { context.Blogs.AddRange( - new Blog { Id = 1 }, - new Blog { Id = 2 }, - new Blog { Id = 3 }); + new Blog { Id = 1, Label = 1, NullableId = 1 }, + new Blog { Id = 2, Label = 2, NullableId = 2 }, + new Blog { Id = 3, Label = 3, NullableId = null }); await context.SaveChangesAsync(); } @@ -73,13 +170,23 @@ protected class BlogContext(DbContextOptions options) : DbContext(options) public DbSet Blogs => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) - => modelBuilder.Entity().Property(b => b.Id) + { + var entity = modelBuilder.Entity(); + entity.Property(b => b.Id) .HasConversion(new ValueConverter(v => v, v => checked((int)v))) .HasColumnType("bigint"); + entity.Property(b => b.Label) + .HasConversion(new ValueConverter(v => v.ToString()!, v => int.Parse(v))); + entity.Property(b => b.NullableId) + .HasConversion(new ValueConverter(v => v, v => (int?)v)) + .HasColumnType("bigint"); + } } protected class Blog { public int Id { get; set; } + public int Label { get; set; } + public int? NullableId { get; set; } } } diff --git a/test/EFCore.PG.Tests/Storage/NpgsqlArrayValueConverterTest.cs b/test/EFCore.PG.Tests/Storage/NpgsqlArrayValueConverterTest.cs index 667759b73..861f7adc1 100644 --- a/test/EFCore.PG.Tests/Storage/NpgsqlArrayValueConverterTest.cs +++ b/test/EFCore.PG.Tests/Storage/NpgsqlArrayValueConverterTest.cs @@ -111,6 +111,68 @@ public void Can_convert_object_lists_through_an_element_converter() Assert.Equal(new List { 1, 2 }, fromProvider([1L, 2L])); } + [ConditionalFact] + public void Can_convert_object_arrays_through_a_string_element_converter() + { + var elementConverter = new ValueConverter(v => v.ToString(), v => int.Parse(v)); + var converter = new NpgsqlArrayConverter(elementConverter); + + var toProvider = (Func)converter.ConvertToProviderExpression.Compile(); + Assert.Equal(new string[] { "1", null, "2" }, toProvider(new object[] { 1, null, 2 })); + + var fromProvider = (Func)converter.ConvertFromProviderExpression.Compile(); + Assert.Equal(new object[] { 1, null, 2 }, fromProvider(new string[] { "1", null, "2" })); + } + + [ConditionalFact] + public void Can_convert_object_lists_through_a_string_element_converter() + { + var elementConverter = new ValueConverter(v => v.ToString(), v => int.Parse(v)); + var converter = new NpgsqlArrayConverter, List, string[]>(elementConverter); + + var toProvider = (Func, string[]>)converter.ConvertToProviderExpression.Compile(); + Assert.Equal(new string[] { "1", null, "2" }, toProvider(new List { 1, null, 2 })); + + var fromProvider = (Func>)converter.ConvertFromProviderExpression.Compile(); + Assert.Equal(new List { 1, null, 2 }, fromProvider(new string[] { "1", null, "2" })); + } + + [ConditionalFact] + public void Can_convert_object_arrays_through_a_string_to_string_converter() + { + var elementConverter = new ValueConverter(v => v, v => v); + var converter = new NpgsqlArrayConverter(elementConverter); + + var toProvider = (Func)converter.ConvertToProviderExpression.Compile(); + Assert.Equal(new string[] { "a", null }, toProvider(new object[] { "a", null })); + } + + [ConditionalFact] + public void Can_preserve_null_elements_in_object_arrays() + { + var elementConverter = new ValueConverter(v => v, v => checked((int)v)); + var converter = new NpgsqlArrayConverter(elementConverter); + + var toProvider = (Func)converter.ConvertToProviderExpression.Compile(); + Assert.Equal(new long?[] { null, 2L }, toProvider(new object[] { null, 2 })); + + var fromProvider = (Func)converter.ConvertFromProviderExpression.Compile(); + Assert.Equal(new object[] { null, 2 }, fromProvider(new long?[] { null, 2L })); + } + + [ConditionalFact] + public void Can_preserve_null_elements_in_object_lists() + { + var elementConverter = new ValueConverter(v => v, v => checked((int)v)); + var converter = new NpgsqlArrayConverter, List, long?[]>(elementConverter); + + var toProvider = (Func, long?[]>)converter.ConvertToProviderExpression.Compile(); + Assert.Equal(new long?[] { null, 2L }, toProvider(new List { null, 2 })); + + var fromProvider = (Func>)converter.ConvertFromProviderExpression.Compile(); + Assert.Equal(new List { null, 2 }, fromProvider(new long?[] { null, 2L })); + } + [ConditionalFact] public void Rejects_an_element_converter_whose_type_does_not_match_the_array() { From 043fecb2db0ee09b6e8926bdb7896740ec9b60e7 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Fri, 16 Oct 2026 10:25:51 +0200 Subject: [PATCH 3/3] Consolidate object collection query tests Reuse the primitive collections fixture for array/list Contains coverage over converted nullable and non-nullable properties. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3c352ad1-a821-46b3-9299-c167671e86bf --- .../ObjectCollectionContainsNpgsqlTest.cs | 192 ------------------ .../PrimitiveCollectionsQueryNpgsqlTest.cs | 40 ++++ 2 files changed, 40 insertions(+), 192 deletions(-) delete mode 100644 test/EFCore.PG.FunctionalTests/Query/ObjectCollectionContainsNpgsqlTest.cs diff --git a/test/EFCore.PG.FunctionalTests/Query/ObjectCollectionContainsNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/ObjectCollectionContainsNpgsqlTest.cs deleted file mode 100644 index e0b0997bc..000000000 --- a/test/EFCore.PG.FunctionalTests/Query/ObjectCollectionContainsNpgsqlTest.cs +++ /dev/null @@ -1,192 +0,0 @@ -namespace Microsoft.EntityFrameworkCore.Query; - -public class ObjectCollectionContainsNpgsqlTest(NonSharedFixture fixture) : NonSharedModelTestBase(fixture), IClassFixture -{ - protected override string NonSharedStoreName - => "ObjectCollectionContainsTest"; - - protected override ITestStoreFactory NonSharedTestStoreFactory - => NpgsqlTestStoreFactory.Instance; - - [ConditionalFact] - public virtual async Task Contains_on_object_array_with_element_value_converter() - { - var contextFactory = await InitializeNonSharedTest(seed: SeedAsync); - - using var context = contextFactory.CreateDbContext(); - var ids = new object[] { 1, 2 }; - - var result = await context.Blogs.Where(b => ids.Contains(b.Id)).OrderBy(b => b.Id).Select(b => b.Id).ToListAsync(); - - Assert.Equal([1, 2], result); - - AssertSql( - """ -@ids={ '1' -'2' } (DbType = Object) - -SELECT b."Id" -FROM "Blogs" AS b -WHERE b."Id" = ANY (@ids) -ORDER BY b."Id" NULLS FIRST -"""); - } - - [ConditionalFact] - public virtual async Task Contains_on_object_list_with_element_value_converter() - { - var contextFactory = await InitializeNonSharedTest(seed: SeedAsync); - - using var context = contextFactory.CreateDbContext(); - var ids = new List { 1, 2 }; - - var result = await context.Blogs.Where(b => ids.Contains(b.Id)).OrderBy(b => b.Id).Select(b => b.Id).ToListAsync(); - - Assert.Equal([1, 2], result); - - AssertSql( - """ -@ids={ '1' -'2' } (DbType = Object) - -SELECT b."Id" -FROM "Blogs" AS b -WHERE b."Id" = ANY (@ids) -ORDER BY b."Id" NULLS FIRST -"""); - } - - [ConditionalFact] - public virtual async Task Contains_on_object_array_with_null_element() - { - var contextFactory = await InitializeNonSharedTest(seed: SeedAsync); - - using var context = contextFactory.CreateDbContext(); - var ids = new object[] { null!, 2 }; - - var result = await context.Blogs.Where(b => ids.Contains(b.Id)).OrderBy(b => b.Id).Select(b => b.Id).ToListAsync(); - - Assert.Equal([2], result); - - AssertSql( - """ -@ids={ NULL -'2' } (DbType = Object) - -SELECT b."Id" -FROM "Blogs" AS b -WHERE b."Id" = ANY (@ids) -ORDER BY b."Id" NULLS FIRST -"""); - } - - [ConditionalFact] - public virtual async Task Contains_on_object_list_with_null_element() - { - var contextFactory = await InitializeNonSharedTest(seed: SeedAsync); - - using var context = contextFactory.CreateDbContext(); - var ids = new List { null!, 2 }; - - var result = await context.Blogs.Where(b => ids.Contains(b.Id)).OrderBy(b => b.Id).Select(b => b.Id).ToListAsync(); - - Assert.Equal([2], result); - - AssertSql( - """ -@ids={ NULL -'2' } (DbType = Object) - -SELECT b."Id" -FROM "Blogs" AS b -WHERE b."Id" = ANY (@ids) -ORDER BY b."Id" NULLS FIRST -"""); - } - - [ConditionalFact] - public virtual async Task Contains_on_object_array_with_string_element_converter() - { - var contextFactory = await InitializeNonSharedTest(seed: SeedAsync); - - using var context = contextFactory.CreateDbContext(); - var labels = new object[] { 1, null!, 2 }; - - var result = await context.Blogs.Where(b => labels.Contains(b.Label)).OrderBy(b => b.Id).Select(b => b.Id).ToListAsync(); - - Assert.Equal([1, 2], result); - - AssertSql( - """ -@labels={ '1' -NULL -'2' } (DbType = Object) - -SELECT b."Id" -FROM "Blogs" AS b -WHERE b."Label" = ANY (@labels) -ORDER BY b."Id" NULLS FIRST -"""); - } - - [ConditionalFact] - public virtual async Task Contains_on_object_array_matches_null_on_nullable_converted_property() - { - var contextFactory = await InitializeNonSharedTest(seed: SeedAsync); - - using var context = contextFactory.CreateDbContext(); - var ids = new object[] { null!, 2 }; - - var result = await context.Blogs.Where(b => ids.Contains(b.NullableId)).OrderBy(b => b.Id).Select(b => b.Id).ToListAsync(); - - Assert.Equal([2, 3], result); - - AssertSql( - """ -@ids={ NULL -'2' } (DbType = Object) - -SELECT b."Id" -FROM "Blogs" AS b -WHERE b."NullableId" = ANY (@ids) OR (b."NullableId" IS NULL AND array_position(@ids, NULL) IS NOT NULL) -ORDER BY b."Id" NULLS FIRST -"""); - } - - private static async Task SeedAsync(BlogContext context) - { - context.Blogs.AddRange( - new Blog { Id = 1, Label = 1, NullableId = 1 }, - new Blog { Id = 2, Label = 2, NullableId = 2 }, - new Blog { Id = 3, Label = 3, NullableId = null }); - await context.SaveChangesAsync(); - } - - protected void AssertSql(params string[] expected) - => ((TestSqlLoggerFactory)ListLoggerFactory).AssertBaseline(expected); - - protected class BlogContext(DbContextOptions options) : DbContext(options) - { - public DbSet Blogs => Set(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - var entity = modelBuilder.Entity(); - entity.Property(b => b.Id) - .HasConversion(new ValueConverter(v => v, v => checked((int)v))) - .HasColumnType("bigint"); - entity.Property(b => b.Label) - .HasConversion(new ValueConverter(v => v.ToString()!, v => int.Parse(v))); - entity.Property(b => b.NullableId) - .HasConversion(new ValueConverter(v => v, v => (int?)v)) - .HasColumnType("bigint"); - } - } - - protected class Blog - { - public int Id { get; set; } - public int Label { get; set; } - public int? NullableId { get; set; } - } -} diff --git a/test/EFCore.PG.FunctionalTests/Query/PrimitiveCollectionsQueryNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/PrimitiveCollectionsQueryNpgsqlTest.cs index 392dcd1f3..1fbf1ab28 100644 --- a/test/EFCore.PG.FunctionalTests/Query/PrimitiveCollectionsQueryNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/PrimitiveCollectionsQueryNpgsqlTest.cs @@ -572,6 +572,46 @@ public virtual async Task Parameter_collection_Dictionary_Values_with_value_conv """); } + [ConditionalTheory] + [InlineData(false)] + [InlineData(true)] + public virtual async Task Parameter_collection_of_objects_with_value_converter_Contains(bool useList) + { + if (useList) + { + List values = [null, new WrappedId(22)]; + + await AssertQuery(ss => ss.Set().Where(c => values.Contains(c.WrappedId))); + await AssertQuery(ss => ss.Set().Where(c => values.Contains(c.NullableWrappedId))); + } + else + { + object?[] values = [null, new WrappedId(22)]; + + await AssertQuery(ss => ss.Set().Where(c => values.Contains(c.WrappedId))); + await AssertQuery(ss => ss.Set().Where(c => values.Contains(c.NullableWrappedId))); + } + + AssertSql( + """ +@values={ NULL +'22' } (DbType = Object) + +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" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."WrappedId" = ANY (@values) +""", + // + """ +@values={ NULL +'22' } (DbType = Object) + +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" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."NullableWrappedId" = ANY (@values) OR (p."NullableWrappedId" IS NULL AND array_position(@values, NULL) IS NOT NULL) +"""); + } + public override async Task Parameter_collection_ImmutableArray_of_ints_Contains_int() { await base.Parameter_collection_ImmutableArray_of_ints_Contains_int();