Skip to content

Commit e2b3d72

Browse files
committed
Fix NpgsqlArrayConverter for one-directional conversions
Fixes #3050
1 parent b16af1b commit e2b3d72

File tree

2 files changed

+56
-23
lines changed

2 files changed

+56
-23
lines changed

src/EFCore.PG/Storage/ValueConversion/NpgsqlArrayConverter.cs

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -131,25 +131,15 @@ private static Expression<Func<TInput, TOutput>> ArrayConversionExpression<TInpu
131131
indexer = i => ArrayAccess(input, i);
132132
break;
133133

134-
// Input is typed as an IList - we can get its length and index into it
134+
// Input is typed as an ICollection - we can get its length, but we can't index into it
135135
case { IsGenericType: true } when inputInterfaces.Append(input.Type)
136-
.Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IList<>)):
136+
.Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ICollection<>)):
137137
{
138138
getInputLength = Property(
139139
input,
140140
input.Type.GetProperty("Count")
141-
// If TInput is an interface (IList<T>), its Count property needs to be found on ICollection<T>
141+
// If TInput is an interface (ICollection<T>), its Count property needs to be found on ICollection<T>
142142
?? typeof(ICollection<>).MakeGenericType(input.Type.GetGenericArguments()[0]).GetProperty("Count")!);
143-
indexer = i => Property(input, input.Type.FindIndexerProperty()!, i);
144-
break;
145-
}
146-
147-
// Input is typed as an ICollection - we can get its length, but we can't index into it
148-
case { IsGenericType: true } when inputInterfaces.Append(input.Type)
149-
.Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ICollection<>)):
150-
{
151-
getInputLength = Property(
152-
input, typeof(ICollection<>).MakeGenericType(input.Type.GetGenericArguments()[0]).GetProperty("Count")!);
153143
indexer = null;
154144
break;
155145
}
@@ -176,6 +166,31 @@ private static Expression<Func<TInput, TOutput>> ArrayConversionExpression<TInpu
176166
throw new NotSupportedException($"Array value converter input type must be an IEnumerable, but is {typeof(TInput)}");
177167
}
178168

169+
Expression? instantiateOutput = typeof(TConcreteOutput) switch
170+
{
171+
var t when t.IsArray => NewArrayBounds(outputElementType, lengthVariable),
172+
var t when typeof(TConcreteOutput).GetConstructor([typeof(int)]) is ConstructorInfo ctorWithLength => New(ctorWithLength, lengthVariable),
173+
var t when typeof(TConcreteOutput).GetConstructor([]) is not null => New(typeof(TConcreteOutput)),
174+
175+
_ => null
176+
};
177+
178+
if (instantiateOutput is null)
179+
{
180+
// If the output type can't be instantiated (no public parameterless constructor), we can't value convert to it.
181+
// If we simply throw and prevent the array converter from being instantiated, that would block scenarios where the user is
182+
// only *writing* an uninstantiable type, but never reading it (see #3050). To allow for such "one-directional" value converters,
183+
// we instead return a lambda that throws, so that if converter is actually ever used to read, it will throw at that point.
184+
// See test Parameter_collection_Dictionary_Valuees_with_value_converter_Contains.
185+
return Lambda<Func<TInput, TOutput>>(
186+
Throw(
187+
New(
188+
typeof(InvalidOperationException).GetConstructor([typeof(string)])!,
189+
Constant($"Type {typeof(TConcreteOutput)} cannot be instantiated as it does not have a public parameterless constructor.")),
190+
typeof(TOutput)),
191+
input);
192+
}
193+
179194
expressions.AddRange(
180195
[
181196
// Get the length of the input array or list
@@ -184,14 +199,7 @@ private static Expression<Func<TInput, TOutput>> ArrayConversionExpression<TInpu
184199

185200
// Allocate an output array or list
186201
// var result = new int[length];
187-
Assign(
188-
output,
189-
typeof(TConcreteOutput) switch
190-
{
191-
var t when t.IsArray => NewArrayBounds(outputElementType, lengthVariable),
192-
var t when typeof(TConcreteOutput).GetConstructor([typeof(int)]) is ConstructorInfo ctorWithLength => New(ctorWithLength, lengthVariable),
193-
_ => New(typeof(TConcreteOutput))
194-
})
202+
Assign(output, instantiateOutput)
195203
]);
196204

197205
if (indexer is not null)
@@ -200,7 +208,7 @@ var t when typeof(TConcreteOutput).GetConstructor([typeof(int)]) is ConstructorI
200208
// the element converter on each element.
201209
// for (var i = 0; i < length; i++)
202210
// {
203-
// result[i] = input[i];
211+
// result[i] = convert(input[i]);
204212
// }
205213
var counter = Parameter(typeof(int), "i");
206214

@@ -232,7 +240,7 @@ elementConversionExpression is null
232240
// counter = 0;
233241
// while (enumerator.MoveNext())
234242
// {
235-
// output[counter] = enumerator.Current;
243+
// output[counter] = convert(enumerator.Current);
236244
// counter++;
237245
// }
238246
var enumerableType = typeof(IEnumerable<>).MakeGenericType(inputElementType);

test/EFCore.PG.FunctionalTests/Query/PrimitiveCollectionsQueryNpgsqlTest.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,31 @@ public virtual async Task Parameter_collection_HashSet_with_value_converter_Cont
533533
""");
534534
}
535535

536+
[ConditionalFact]
537+
public virtual async Task Parameter_collection_Dictionary_Values_with_value_converter_Contains()
538+
{
539+
Dictionary<int, MyEnum> enums = new()
540+
{
541+
[0] = MyEnum.Value1,
542+
[1] = MyEnum.Value4
543+
};
544+
545+
// Dictionary<>.ValuesCollection doesn't have a public parameterless constructor, so NpgsqlArrayConverter can't convert to it
546+
// (see #3050). We still allow NpgsqlArrayConverter to be built to allow one-directional conversion: in the query below,
547+
// we only need to write enum.Values as a parameter (never read it).
548+
await AssertQuery(ss => ss.Set<PrimitiveCollectionsEntity>().Where(c => enums.Values.Contains(c.Enum)));
549+
550+
AssertSql(
551+
"""
552+
@enums_Values={ '0'
553+
'3' } (DbType = Object)
554+
555+
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"
556+
FROM "PrimitiveCollectionsEntity" AS p
557+
WHERE p."Enum" = ANY (@enums_Values)
558+
""");
559+
}
560+
536561
public override async Task Parameter_collection_ImmutableArray_of_ints_Contains_int()
537562
{
538563
await base.Parameter_collection_ImmutableArray_of_ints_Contains_int();

0 commit comments

Comments
 (0)