From 1cdeb656435ef6ea3bb8a185e08e572d0c95d3c3 Mon Sep 17 00:00:00 2001 From: Vladimir Nosov Date: Mon, 27 Jul 2026 15:53:26 +0300 Subject: [PATCH] Fix CreateParameter for collection shape mismatch with element value converters --- .../Mapping/NpgsqlArrayTypeMapping.cs | 27 ++++---- .../Query/ArrayArrayQueryTest.cs | 62 +++++++++++++++++++ .../Query/ArrayListQueryTest.cs | 62 +++++++++++++++++++ .../Storage/NpgsqlTypeMappingSourceTest.cs | 43 +++++++++++++ 4 files changed, 182 insertions(+), 12 deletions(-) diff --git a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayTypeMapping.cs b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayTypeMapping.cs index 749e3d3a0a..0ebfa545ce 100644 --- a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayTypeMapping.cs +++ b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayTypeMapping.cs @@ -232,22 +232,25 @@ public override DbParameter CreateParameter( { // In queries which compose non-server-correlated LINQ operators over an array parameter (e.g. Where(b => ids.Skip(1)...) we // get an enumerable parameter value that isn't an array/list - but those aren't supported at the Npgsql ADO level. - // Detect this here and evaluate the enumerable to get a fully materialized List. - // Note that when we have a value converter (e.g. for HashSet), we don't want to convert it to a List, since the value converter - // expects the original type. + // Detect this here and evaluate the enumerable to get a fully materialized TConcreteCollection (array or List). + // Note that when we have a value converter (e.g. for HashSet), we don't want to convert values that already match + // the converter's model type, since the value converter expects that original type. + // However, if the value's collection shape differs from the converter model type (e.g. List vs MyId[] after + // type mapping inference for Intersect().Any() → &&), normalize to TConcreteCollection so Sanitize succeeds. // TODO: Make Npgsql support IList<> instead of only arrays and List<> - if (value is not null && Converter is null && !value.GetType().IsArrayOrGenericList()) + if (value is not null + && ((Converter is null && !value.GetType().IsArrayOrGenericList()) + || (Converter is not null && !Converter.ModelClrType.IsInstanceOfType(value)))) { - switch (value) + var castedElements = value switch { - case IEnumerable elements: - value = elements.ToList(); - break; + IEnumerable elements => elements, + IEnumerable elements => elements.Cast(), + _ => throw new InvalidOperationException( + $"Cannot create a parameter for {GetType().Name} from value of type '{value.GetType().Name}'") + }; - case IEnumerable elements: - value = elements.Cast().ToList(); - break; - } + value = typeof(TConcreteCollection).IsArray ? castedElements.ToArray() : castedElements.ToList(); } var param = base.CreateParameter(command, name, value, nullable, direction); diff --git a/test/EFCore.PG.FunctionalTests/Query/ArrayArrayQueryTest.cs b/test/EFCore.PG.FunctionalTests/Query/ArrayArrayQueryTest.cs index 083d4fa224..6204d40c64 100644 --- a/test/EFCore.PG.FunctionalTests/Query/ArrayArrayQueryTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/ArrayArrayQueryTest.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using Microsoft.EntityFrameworkCore.TestModels.Array; using Npgsql.EntityFrameworkCore.PostgreSQL.Internal; @@ -867,6 +868,67 @@ public virtual async Task All_Contains() #endregion Any/All + #region Intersect + + [ConditionalFact] + public virtual async Task Intersect_parameter_list_over_value_converted_array() + { + List toFindList = [SomeEnum.One, SomeEnum.Three, SomeEnum.Eight]; + + await AssertQuery(ss => ss.Set().Where(e => e.ValueConvertedArrayOfEnum.Intersect(toFindList).Any())); + + AssertSql( + """ +@toFindList={ 'One' +'Three' +'Eight' } (DbType = Object) + +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."ValueConvertedArrayOfEnum" && @toFindList +"""); + } + + [ConditionalFact] + public virtual async Task Intersect_parameter_immutable_list_over_value_converted_array() + { + ImmutableList toFindImmutableList = [SomeEnum.One, SomeEnum.Three, SomeEnum.Eight]; + + await AssertQuery(ss => ss.Set().Where(e => e.ValueConvertedArrayOfEnum.Intersect(toFindImmutableList).Any())); + + AssertSql( + """ +@toFindImmutableList={ 'One' +'Three' +'Eight' } (DbType = Object) + +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."ValueConvertedArrayOfEnum" && @toFindImmutableList +"""); + } + + [ConditionalFact] + public virtual async Task Intersect_parameter_array_over_value_converted_array() + { + SomeEnum[] toFindArray = [SomeEnum.One, SomeEnum.Three, SomeEnum.Eight]; + + await AssertQuery(ss => ss.Set().Where(e => e.ValueConvertedArrayOfEnum.Intersect(toFindArray).Any())); + + AssertSql( + """ +@toFindArray={ 'One' +'Three' +'Eight' } (DbType = Object) + +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."ValueConvertedArrayOfEnum" && @toFindArray +"""); + } + + #endregion + #region Other translations [ConditionalFact] diff --git a/test/EFCore.PG.FunctionalTests/Query/ArrayListQueryTest.cs b/test/EFCore.PG.FunctionalTests/Query/ArrayListQueryTest.cs index 12dadfd911..33c20f60d9 100644 --- a/test/EFCore.PG.FunctionalTests/Query/ArrayListQueryTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/ArrayListQueryTest.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using Microsoft.EntityFrameworkCore.TestModels.Array; namespace Microsoft.EntityFrameworkCore.Query; @@ -873,6 +874,67 @@ public virtual async Task All_Contains() #endregion Any/All + #region Intersect + + [ConditionalFact] + public virtual async Task Intersect_parameter_array_over_value_converted_list() + { + SomeEnum[] toFindArray = [SomeEnum.One, SomeEnum.Three, SomeEnum.Eight]; + + await AssertQuery(ss => ss.Set().Where(e => e.ValueConvertedListOfEnum.Intersect(toFindArray).Any())); + + AssertSql( + """ +@toFindArray={ 'One' +'Three' +'Eight' } (DbType = Object) + +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."ValueConvertedListOfEnum" && @toFindArray +"""); + } + + [ConditionalFact] + public virtual async Task Intersect_parameter_immutable_list_over_value_converted_list() + { + ImmutableList toFindImmutableList = [SomeEnum.One, SomeEnum.Three, SomeEnum.Eight]; + + await AssertQuery(ss => ss.Set().Where(e => e.ValueConvertedListOfEnum.Intersect(toFindImmutableList).Any())); + + AssertSql( + """ +@toFindImmutableList={ 'One' +'Three' +'Eight' } (DbType = Object) + +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."ValueConvertedListOfEnum" && @toFindImmutableList +"""); + } + + [ConditionalFact] + public virtual async Task Intersect_parameter_list_over_value_converted_list() + { + List toFindList = [SomeEnum.One, SomeEnum.Three, SomeEnum.Eight]; + + await AssertQuery(ss => ss.Set().Where(e => e.ValueConvertedListOfEnum.Intersect(toFindList).Any())); + + AssertSql( + """ +@toFindList={ 'One' +'Three' +'Eight' } (DbType = Object) + +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."ValueConvertedListOfEnum" && @toFindList +"""); + } + + #endregion + #region Other translations // TODO: https://github.com/dotnet/efcore/issues/30669 diff --git a/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingSourceTest.cs b/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingSourceTest.cs index b1ec3c98b5..d59be57dcb 100644 --- a/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingSourceTest.cs +++ b/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingSourceTest.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System.Net; using System.Net.NetworkInformation; using System.Text.Json; @@ -263,6 +264,48 @@ public void Array_over_type_mapping_with_value_converter_by_clr_type_list() public void Array_over_type_mapping_with_value_converter_by_store_type() => Array_over_type_mapping_with_value_converter(CreateTypeMappingSource().FindMapping("ltree[]"), typeof(List)); + [Theory] + [InlineData(typeof(LTree[]))] + [InlineData(typeof(List))] + public void CreateParameter_with_value_converter_accepts_list(Type mappingType) + { + var mapping = CreateTypeMappingSource().FindMapping(mappingType)!; + Assert.NotNull(mapping.Converter); + var parameter = mapping.CreateParameter( + new NpgsqlCommand(), + "p", + new List { new("foo"), new("bar") }); + Assert.Equal(["foo", "bar"], Assert.IsType(parameter.Value)); + } + + [Theory] + [InlineData(typeof(LTree[]))] + [InlineData(typeof(List))] + public void CreateParameter_with_value_converter_accepts_immutable_list(Type mappingType) + { + var mapping = CreateTypeMappingSource().FindMapping(mappingType)!; + Assert.NotNull(mapping.Converter); + var parameter = mapping.CreateParameter( + new NpgsqlCommand(), + "p", + ImmutableList.Create(new LTree("foo"), new LTree("bar"))); + Assert.Equal(["foo", "bar"], Assert.IsType(parameter.Value)); + } + + [Theory] + [InlineData(typeof(LTree[]))] + [InlineData(typeof(List))] + public void CreateParameter_with_value_converter_accepts_array(Type mappingType) + { + var mapping = CreateTypeMappingSource().FindMapping(mappingType)!; + Assert.NotNull(mapping.Converter); + var parameter = mapping.CreateParameter( + new NpgsqlCommand(), + "p", + new LTree[] { new("foo"), new("bar") }); + Assert.Equal(["foo", "bar"], Assert.IsType(parameter.Value)); + } + private void Array_over_type_mapping_with_value_converter(CoreTypeMapping mapping, Type expectedType) { var arrayMapping = (NpgsqlArrayTypeMapping)mapping;