diff --git a/src/LinqTests/Bugs/compiled_query_problem_with_search_by_string_and_string_collections.cs b/src/LinqTests/Bugs/compiled_query_problem_with_search_by_string_and_string_collections.cs new file mode 100644 index 0000000000..af19457c11 --- /dev/null +++ b/src/LinqTests/Bugs/compiled_query_problem_with_search_by_string_and_string_collections.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using Marten; +using Marten.Linq; +using Marten.Testing.Documents; +using Marten.Testing.Harness; +using Shouldly; + +namespace LinqTests.Bugs; + +public class compiled_query_problem_with_search_by_string_and_string_collections(DefaultStoreFixture fixture): IntegrationContext(fixture) +{ + protected override async Task fixtureSetup() + { + await theStore.Advanced.ResetAllData(); + } + + public class IssuesByTitles: ICompiledListQuery, IQueryPlanning + { + public required string[] Titles { get; set; } + public required string Status { get; set; } + + public Expression, IEnumerable>> QueryIs() + { + return query => query.Where(x => x.Status == Status && x.Title.IsOneOf(Titles)); + } + void IQueryPlanning.SetUniqueValuesForQueryPlanning() + { + Status = "status"; + Titles = ["title"]; + } + } + + [Fact] + public async Task can_search_isOneOf_strings_with_compiled_queries_and_query_planning() + { + var issue1 = new Issue { Title = "Issue1", Status = "Open" }; + var issue2 = new Issue { Title = "Issue2", Status = "Open"}; + var issue3 = new Issue { Title = "Issue3", Status = "Open" }; + + theSession.Store(issue1, issue2, issue3); + await theSession.SaveChangesAsync(); + + await using var session = theStore.QuerySession(); + var query = new IssuesByTitles { Titles = [issue1.Title, issue2.Title], Status = issue1.Status }; + var issues = await session.QueryAsync(query); + + issues.Count().ShouldBe(2); + } +} diff --git a/src/Marten/Internal/CompiledQueries/ArrayParameterFinder.cs b/src/Marten/Internal/CompiledQueries/ArrayParameterFinder.cs new file mode 100644 index 0000000000..3ba454f96a --- /dev/null +++ b/src/Marten/Internal/CompiledQueries/ArrayParameterFinder.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Marten.Internal.CompiledQueries; + +/// +/// Parameter finder for array-typed query members (string[], Guid[], int[], etc.) +/// used in compiled queries with operators like IsOneOf(). +/// +internal class ArrayParameterFinder : IParameterFinder +{ + private readonly Func _uniqueElementValues; + + public ArrayParameterFinder(Func uniqueElementValues) + { + _uniqueElementValues = uniqueElementValues; + } + + public Type DotNetType => typeof(TElement[]); + + public Queue UniqueValueQueue(Type type) + { + // Each unique value is itself a TElement[] with unique content + var queue = new Queue(); + for (var i = 0; i < 20; i++) + { + queue.Enqueue(_uniqueElementValues(i + 1)); + } + return queue; + } + + public bool Matches(Type memberType) + { + return memberType == typeof(TElement[]); + } + + public bool AreValuesUnique(object query, CompiledQueryPlan plan) + { + var members = plan.QueryMembers.OfType>().ToArray(); + + if (members.Length == 0) + { + return true; + } + + // For arrays, check that each member has a distinct array (by reference or content) + return members.Select(x => x.GetValue(query)) + .Distinct(new ArrayContentComparer()) + .Count() == members.Length; + } +} + +internal class ArrayContentComparer : IEqualityComparer +{ + public bool Equals(T[]? x, T[]? y) + { + if (ReferenceEquals(x, y)) return true; + if (x == null || y == null) return false; + return x.SequenceEqual(y); + } + + public int GetHashCode(T[]? obj) + { + if (obj == null) return 0; + unchecked + { + var hash = 17; + foreach (var item in obj) + { + hash = hash * 31 + (item?.GetHashCode() ?? 0); + } + return hash; + } + } +} diff --git a/src/Marten/Internal/CompiledQueries/CompiledQueryPlan.cs b/src/Marten/Internal/CompiledQueries/CompiledQueryPlan.cs index 89dbc65d10..f2155e4654 100644 --- a/src/Marten/Internal/CompiledQueries/CompiledQueryPlan.cs +++ b/src/Marten/Internal/CompiledQueries/CompiledQueryPlan.cs @@ -67,6 +67,22 @@ private void sortMembers() { IncludeMembers.Add(member); } + else if (memberType.IsArray && QueryCompiler.Finders.Any(x => x.Matches(memberType.GetElementType()!))) + { + // Arrays like string[], int[], Guid[] etc. whose element type has a registered + // parameter finder should be treated as query parameters, NOT as include members. + // This check must come before the IList<> check since arrays implement IList. + if (member is PropertyInfo) + { + var queryMember = typeof(PropertyQueryMember<>).CloseAndBuildAs(member, memberType); + QueryMembers.Add(queryMember); + } + else if (member is FieldInfo) + { + var queryMember = typeof(FieldQueryMember<>).CloseAndBuildAs(member, memberType); + QueryMembers.Add(queryMember); + } + } else if (memberType.Closes(typeof(IList<>))) { IncludeMembers.Add(member); diff --git a/src/Marten/Internal/CompiledQueries/ParameterUsage.cs b/src/Marten/Internal/CompiledQueries/ParameterUsage.cs index 0328f6a785..a2ee8671c4 100644 --- a/src/Marten/Internal/CompiledQueries/ParameterUsage.cs +++ b/src/Marten/Internal/CompiledQueries/ParameterUsage.cs @@ -87,10 +87,47 @@ private static string npgsqlDataTypeInCodeFor(NpgsqlParameter parameter) private void generateSimpleCode(GeneratedMethod method, MemberInfo member, Type memberType, string parametersVariableName) { - method.Frames.Code($@" + // Array types like string[], Guid[], int[] need composite NpgsqlDbType (Array | ElementType) + // which can't be passed as a single enum value to the code generation template + if (memberType.IsArray) + { + var dbTypeCode = npgsqlArrayDbTypeCodeFor(memberType); + method.Frames.Code( + $"{parametersVariableName}[{Index}].NpgsqlDbType = {dbTypeCode};\n" + + $"{parametersVariableName}[{Index}].Value = _query.{member.Name};"); + } + else + { + method.Frames.Code($@" {parametersVariableName}[{Index}].NpgsqlDbType = {{0}}; {parametersVariableName}[{Index}].Value = _query.{member.Name}; ", PostgresqlProvider.Instance.ToParameterType(memberType)); + } + } + + private static string npgsqlArrayDbTypeCodeFor(Type arrayType) + { + var elementType = arrayType.GetElementType()!; + var npgsqlTypeName = typeof(NpgsqlDbType).FullNameInCode(); + + if (elementType == typeof(string)) + return $"{npgsqlTypeName}.{NpgsqlDbType.Array} | {npgsqlTypeName}.{NpgsqlDbType.Varchar}"; + if (elementType == typeof(Guid)) + return $"{npgsqlTypeName}.{NpgsqlDbType.Array} | {npgsqlTypeName}.{NpgsqlDbType.Uuid}"; + if (elementType == typeof(int)) + return $"{npgsqlTypeName}.{NpgsqlDbType.Array} | {npgsqlTypeName}.{NpgsqlDbType.Integer}"; + if (elementType == typeof(long)) + return $"{npgsqlTypeName}.{NpgsqlDbType.Array} | {npgsqlTypeName}.{NpgsqlDbType.Bigint}"; + if (elementType == typeof(float)) + return $"{npgsqlTypeName}.{NpgsqlDbType.Array} | {npgsqlTypeName}.{NpgsqlDbType.Real}"; + if (elementType == typeof(decimal)) + return $"{npgsqlTypeName}.{NpgsqlDbType.Array} | {npgsqlTypeName}.{NpgsqlDbType.Numeric}"; + if (elementType == typeof(DateTime)) + return $"{npgsqlTypeName}.{NpgsqlDbType.Array} | {npgsqlTypeName}.{NpgsqlDbType.Timestamp}"; + if (elementType == typeof(DateTimeOffset)) + return $"{npgsqlTypeName}.{NpgsqlDbType.Array} | {npgsqlTypeName}.{NpgsqlDbType.TimestampTz}"; + + throw new NotSupportedException($"Array type {arrayType.FullNameInCode()} is not supported for compiled query parameters"); } private void generateEnumCode(GeneratedMethod method, StoreOptions storeOptions, MemberInfo member, diff --git a/src/Marten/Internal/CompiledQueries/QueryCompiler.cs b/src/Marten/Internal/CompiledQueries/QueryCompiler.cs index 5e58b0c2c1..b9d614bd68 100644 --- a/src/Marten/Internal/CompiledQueries/QueryCompiler.cs +++ b/src/Marten/Internal/CompiledQueries/QueryCompiler.cs @@ -111,6 +111,32 @@ static QueryCompiler() return values; }); + + // Register array-type finders for compiled query members used with IsOneOf() etc. + forArrayType(count => + { + var values = new string[count]; + for (var i = 0; i < count; i++) values[i] = $"_plan_{Guid.NewGuid():N}"; + return values; + }); + forArrayType(count => + { + var values = new Guid[count]; + for (var i = 0; i < count; i++) values[i] = Guid.NewGuid(); + return values; + }); + forArrayType(count => + { + var values = new int[count]; + for (var i = 0; i < count; i++) values[i] = -(500000 + i); + return values; + }); + forArrayType(count => + { + var values = new long[count]; + for (var i = 0; i < count; i++) values[i] = -(600000L + i); + return values; + }); } private static void forType(Func uniqueValues) @@ -119,6 +145,12 @@ private static void forType(Func uniqueValues) Finders.Add(finder); } + private static void forArrayType(Func uniqueElementValues) + { + var finder = new ArrayParameterFinder(uniqueElementValues); + Finders.Add(finder); + } + public static CompiledQueryPlan BuildQueryPlan(QuerySession session, Type queryType, StoreOptions storeOptions) { var querySignature = queryType.FindInterfaceThatCloses(typeof(ICompiledQuery<,>)); diff --git a/src/Marten/Internal/CompiledQueries/QueryMember.cs b/src/Marten/Internal/CompiledQueries/QueryMember.cs index fe7e542776..e6fb74b542 100644 --- a/src/Marten/Internal/CompiledQueries/QueryMember.cs +++ b/src/Marten/Internal/CompiledQueries/QueryMember.cs @@ -72,10 +72,29 @@ public void TryWriteValue(UniqueValueSource valueSource, object query) public MemberInfo Member { get; } + private static bool valuesAreEqual(object value, object? parameterValue) + { + if (value.Equals(parameterValue)) return true; + + // For array types (string[], Guid[], int[], etc.), Equals() does reference comparison. + // We need structural comparison to match array parameter values from compiled query planning. + if (value is Array valueArray && parameterValue is Array paramArray) + { + if (valueArray.Length != paramArray.Length) return false; + for (var i = 0; i < valueArray.Length; i++) + { + if (!Equals(valueArray.GetValue(i), paramArray.GetValue(i))) return false; + } + return true; + } + + return false; + } + private bool tryToFind(NpgsqlParameter parameter, ICompiledQueryAwareFilter[] filters, object value, out ICompiledQueryAwareFilter? filterUsed) { - if (filters.All(x => x.ParameterName != parameter.ParameterName) && value.Equals(parameter.Value)) + if (filters.All(x => x.ParameterName != parameter.ParameterName) && valuesAreEqual(value, parameter.Value)) { filterUsed = null; return true;