diff --git a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayTypeMapping.cs b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayTypeMapping.cs index 749e3d3a0..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,20 +134,26 @@ 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), - elementMapping.Comparer.ToNullableComparer(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; 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/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(); diff --git a/test/EFCore.PG.Tests/Storage/NpgsqlArrayValueConverterTest.cs b/test/EFCore.PG.Tests/Storage/NpgsqlArrayValueConverterTest.cs index ed2b9d7e0..861f7adc1 100644 --- a/test/EFCore.PG.Tests/Storage/NpgsqlArrayValueConverterTest.cs +++ b/test/EFCore.PG.Tests/Storage/NpgsqlArrayValueConverterTest.cs @@ -83,6 +83,107 @@ 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 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() + { + 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,