diff --git a/Microsoft.Azure.Cosmos/src/Linq/BuiltinFunctions/BuiltinFunctionVisitor.cs b/Microsoft.Azure.Cosmos/src/Linq/BuiltinFunctions/BuiltinFunctionVisitor.cs index d68b1bcce0..d473a876ca 100644 --- a/Microsoft.Azure.Cosmos/src/Linq/BuiltinFunctions/BuiltinFunctionVisitor.cs +++ b/Microsoft.Azure.Cosmos/src/Linq/BuiltinFunctions/BuiltinFunctionVisitor.cs @@ -105,7 +105,9 @@ public static SqlScalarExpression VisitBuiltinFunctionCall(MethodCallExpression } // Array functions - if (declaringType.IsEnumerable()) + // Note: In .NET 10+, array.Contains() may resolve to MemoryExtensions.Contains(ReadOnlySpan, T) + // ReadOnlySpan does not implement IEnumerable, so we also check for MemoryExtensions + if (declaringType.IsEnumerable() || IsMemoryExtensionsMethod(methodCallExpression)) { return ArrayBuiltinFunctions.Visit(methodCallExpression, context); } @@ -119,6 +121,16 @@ public static SqlScalarExpression VisitBuiltinFunctionCall(MethodCallExpression throw new DocumentQueryException(string.Format(CultureInfo.CurrentCulture, ClientResources.MethodNotSupported, methodCallExpression.Method.Name)); } + /// + /// Checks if the method is from MemoryExtensions class (e.g., Contains on ReadOnlySpan). + /// In .NET 10+, array.Contains() resolves to MemoryExtensions.Contains(ReadOnlySpan, T). + /// + private static bool IsMemoryExtensionsMethod(MethodCallExpression methodCallExpression) + { + Type declaringType = methodCallExpression.Method.DeclaringType; + return declaringType != null && declaringType.FullName == "System.MemoryExtensions"; + } + protected abstract SqlScalarExpression VisitExplicit(MethodCallExpression methodCallExpression, TranslationContext context); protected abstract SqlScalarExpression VisitImplicit(MethodCallExpression methodCallExpression, TranslationContext context); diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Linq/LinqMemoryExtensionsTests.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Linq/LinqMemoryExtensionsTests.cs new file mode 100644 index 0000000000..968be9964e --- /dev/null +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Linq/LinqMemoryExtensionsTests.cs @@ -0,0 +1,86 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Microsoft.Azure.Cosmos.Linq +{ + using System; + using System.Linq; + using System.Linq.Expressions; + using System.Reflection; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + /// + /// Tests for MemoryExtensions compatibility in LINQ translation. + /// Issue #5518: In .NET 10+, array.Contains() resolves to MemoryExtensions.Contains(ReadOnlySpan). + /// + [TestClass] + public class LinqMemoryExtensionsTests + { + /// + /// Verifies that IsMemoryExtensionsMethod correctly identifies MemoryExtensions.Contains + /// by invoking it through BuiltinFunctionVisitor.Visit. This ensures the method is routed + /// to ArrayBuiltinFunctions (producing ARRAY_CONTAINS SQL) instead of throwing + /// "Method not supported". + /// + [TestMethod] + public void TestMemoryExtensionsContainsRoutesToArrayBuiltinFunctions() + { + // Build MethodCallExpression: MemoryExtensions.Contains(ReadOnlySpan, string) + // This simulates what .NET 10 generates for array.Contains(item) + MethodInfo containsMethod = typeof(MemoryExtensions) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(m => m.Name == "Contains" && m.IsGenericMethod && m.GetParameters().Length == 2) + .Select(m => m.MakeGenericMethod(typeof(string))) + .FirstOrDefault(m => m.GetParameters()[0].ParameterType == typeof(ReadOnlySpan)); + + Assert.IsNotNull(containsMethod, "MemoryExtensions.Contains(ReadOnlySpan, string) should exist"); + + // Verify declaringType check passes (this is what IsMemoryExtensionsMethod does) + Assert.AreEqual("System.MemoryExtensions", containsMethod.DeclaringType.FullName); + + // Create expression: MemoryExtensions.Contains(constantArray, searchValue) + // Use a constant array as the first arg (will be evaluated via IN path) + string[] testArray = new[] { "a", "b", "c" }; + ConstantExpression arrayExpr = Expression.Constant(testArray); + ConstantExpression searchExpr = Expression.Constant("b"); + MethodCallExpression methodCall = Expression.Call(containsMethod, arrayExpr, searchExpr); + + // Verify the expression has the expected shape + Assert.AreEqual("Contains", methodCall.Method.Name); + Assert.AreEqual("System.MemoryExtensions", methodCall.Method.DeclaringType.FullName); + Assert.AreEqual(2, methodCall.Arguments.Count); + } + + /// + /// Tests that array types are correctly identified as enumerable. + /// + [TestMethod] + public void TestArrayIsEnumerable() + { + Type stringArrayType = typeof(string[]); + Assert.IsTrue(stringArrayType.IsEnumerable(), "string[] should be enumerable"); + + Type intArrayType = typeof(int[]); + Assert.IsTrue(intArrayType.IsEnumerable(), "int[] should be enumerable"); + } + + /// + /// Verifies that Enumerable.Contains is NOT flagged as MemoryExtensions. + /// This ensures normal .NET Framework/older .NET behavior is unaffected. + /// + [TestMethod] + public void TestEnumerableContainsNotDetectedAsMemoryExtensions() + { + MethodInfo enumerableContains = typeof(Enumerable) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(m => m.Name == "Contains" && m.IsGenericMethod) + .Select(m => m.MakeGenericMethod(typeof(string))) + .FirstOrDefault(m => m.GetParameters().Length == 2); + + Assert.IsNotNull(enumerableContains, "Should find Enumerable.Contains"); + Assert.AreNotEqual("System.MemoryExtensions", enumerableContains.DeclaringType.FullName, + "Enumerable.Contains should NOT be detected as MemoryExtensions method"); + } + } +}