diff --git a/src/LinqTests/Bugs/Bug_4332_string_array_contains_under_nullable_method_init.cs b/src/LinqTests/Bugs/Bug_4332_string_array_contains_under_nullable_method_init.cs new file mode 100644 index 0000000000..8fbc503ad1 --- /dev/null +++ b/src/LinqTests/Bugs/Bug_4332_string_array_contains_under_nullable_method_init.cs @@ -0,0 +1,101 @@ +#nullable enable +using System; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using Marten; +using Marten.Testing.Documents; +using Marten.Testing.Harness; +using Shouldly; + +namespace LinqTests.Bugs; + +public class Bug_4332_string_array_contains_under_nullable_method_init: BugIntegrationContext +{ + private static string[] MakeNames(params string[] names) => names; + + // Reproduces https://github.com/JasperFx/marten/issues/4332. + // + // Under C# 14 with enable, when a string[] local is + // initialised from a method-typed return (var v = MakeNames(...);), the + // compiler emits the Where predicate with an extra Convert() wrapper + // around the captured closure field: + // + // s => op_Implicit(Convert(closureField, String[])).Contains(s.UserName) + // + // MemoryExtensionsContains.UnwrapConversions only peels op_Implicit, so + // the receiver fails to reduce to a constant and the parser falls through + // to ValueCollectionMember.ParseWhereForContains, which CompileFasts the + // whole lambda and throws InvalidOperationException about 's' not being + // defined in scope. + [Fact] + public async Task can_query_when_string_array_local_is_method_initialised() + { + theSession.Store(new User { UserName = "alice" }); + theSession.Store(new User { UserName = "bob" }); + await theSession.SaveChangesAsync(); + + var values = MakeNames("alice"); + + var results = await theSession.Query() + .Where(u => values.Contains(u.UserName)) + .ToListAsync(); + + results.Count.ShouldBe(1); + results[0].UserName.ShouldBe("alice"); + } + + // Same scenario, but the failing expression shape is constructed by hand + // so the bug reproduces deterministically regardless of which target + // framework / C# language version the test project was built against. + [Fact] + public async Task can_query_when_collection_expression_has_convert_wrapper() + { + theSession.Store(new User { UserName = "alice" }); + theSession.Store(new User { UserName = "bob" }); + await theSession.SaveChangesAsync(); + + var holder = new ValuesHolder { Values = new[] { "alice" } }; + var lambda = BuildPredicateWithConvertWrapper(holder); + + var results = await theSession.Query().Where(lambda).ToListAsync(); + + results.Count.ShouldBe(1); + results[0].UserName.ShouldBe("alice"); + } + + private static Expression> BuildPredicateWithConvertWrapper(ValuesHolder holder) + { + var s = Expression.Parameter(typeof(User), "s"); + + // The captured-closure field, then a Convert() wrapper to string[]: + // Convert(holder.Values, String[]) + var fieldAccess = Expression.Field(Expression.Constant(holder), nameof(ValuesHolder.Values)); + var converted = Expression.Convert(fieldAccess, typeof(string[])); + + // Implicit string[] -> ReadOnlySpan, exactly what C# emits. + var opImplicit = typeof(ReadOnlySpan).GetMethod( + "op_Implicit", + new[] { typeof(string[]) })!; + var asSpan = Expression.Call(opImplicit, converted); + + // MemoryExtensions.Contains(ReadOnlySpan, string) + var containsOpen = typeof(MemoryExtensions).GetMethods() + .Single(m => m.Name == "Contains" + && m.IsGenericMethodDefinition + && m.GetParameters().Length == 2 + && m.GetParameters()[0].ParameterType.IsGenericType + && m.GetParameters()[0].ParameterType.GetGenericTypeDefinition() + == typeof(ReadOnlySpan<>)); + var contains = containsOpen.MakeGenericMethod(typeof(string)); + + var nameMember = Expression.Property(s, nameof(User.UserName)); + var body = Expression.Call(contains, asSpan, nameMember); + return Expression.Lambda>(body, s); + } + + private class ValuesHolder + { + public string[] Values = Array.Empty(); + } +} diff --git a/src/Marten/Linq/Parsing/Methods/MemoryExtensionsContains.cs b/src/Marten/Linq/Parsing/Methods/MemoryExtensionsContains.cs index 4553bc10d9..cb2044e5cb 100644 --- a/src/Marten/Linq/Parsing/Methods/MemoryExtensionsContains.cs +++ b/src/Marten/Linq/Parsing/Methods/MemoryExtensionsContains.cs @@ -44,11 +44,34 @@ public ISqlFragment Parse(IQueryableMemberCollection memberCollection, IReadOnly private static Expression UnwrapConversions(Expression expression) { - // Unwrap op_Implicit method calls - if (expression is MethodCallExpression { Method.Name: "op_Implicit", Arguments.Count: > 0 } methodCall) + // C# 14 with enable can wrap captured-closure + // string[] locals with an extra Convert() before the implicit + // string[] -> ReadOnlySpan conversion, e.g. + // op_Implicit(Convert(closureField, String[])).Contains(s.Name) + // Strip both forms (and any stacked combination) so the receiver can + // still be reduced to a constant. + while (true) { - return methodCall.Arguments[0]; + if (expression is MethodCallExpression + { + Method.Name: "op_Implicit" or "op_Explicit", + Arguments.Count: > 0 + } methodCall) + { + expression = methodCall.Arguments[0]; + continue; + } + + if (expression is UnaryExpression + { + NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked + } unary) + { + expression = unary.Operand; + continue; + } + + return expression; } - return expression; } }