diff --git a/Microsoft.Azure.Cosmos/src/Linq/BuiltinFunctions/ArrayBuiltinFunctions.cs b/Microsoft.Azure.Cosmos/src/Linq/BuiltinFunctions/ArrayBuiltinFunctions.cs index d96ac56a22..573edd1166 100644 --- a/Microsoft.Azure.Cosmos/src/Linq/BuiltinFunctions/ArrayBuiltinFunctions.cs +++ b/Microsoft.Azure.Cosmos/src/Linq/BuiltinFunctions/ArrayBuiltinFunctions.cs @@ -4,6 +4,7 @@ namespace Microsoft.Azure.Cosmos.Linq { + using System; using System.Collections; using System.Collections.Generic; using System.Collections.Immutable; @@ -65,6 +66,10 @@ protected override SqlScalarExpression VisitImplicit(MethodCallExpression method return null; } + // In .NET 10+, the searchList may be wrapped in an op_Implicit conversion + // from T[] to ReadOnlySpan. Unwrap it to get the underlying array constant. + searchList = UnwrapSpanImplicitConversion(searchList); + if (searchList.NodeType == ExpressionType.Constant) { return this.VisitIN(searchExpression, (ConstantExpression)searchList, context); @@ -75,6 +80,29 @@ protected override SqlScalarExpression VisitImplicit(MethodCallExpression method return SqlFunctionCallScalarExpression.CreateBuiltin("ARRAY_CONTAINS", array, expression); } + /// + /// Unwraps an op_Implicit conversion from T[] to ReadOnlySpan<T> or Span<T>, + /// returning the inner array expression. Returns the original expression if not a Span conversion. + /// + private static Expression UnwrapSpanImplicitConversion(Expression expression) + { + if (expression is MethodCallExpression call + && call.Method.Name == "op_Implicit" + && call.Arguments.Count == 1) + { + Type declaringType = call.Method.DeclaringType; + if (declaringType != null + && declaringType.IsGenericType + && (declaringType.GetGenericTypeDefinition() == typeof(ReadOnlySpan<>) + || declaringType.GetGenericTypeDefinition() == typeof(Span<>))) + { + return call.Arguments[0]; + } + } + + return expression; + } + private SqlScalarExpression VisitIN(Expression expression, ConstantExpression constantExpressionList, TranslationContext context) { List items = new List(); diff --git a/Microsoft.Azure.Cosmos/src/Linq/BuiltinFunctions/BuiltinFunctionVisitor.cs b/Microsoft.Azure.Cosmos/src/Linq/BuiltinFunctions/BuiltinFunctionVisitor.cs index d68b1bcce0..bed385aa61 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,15 @@ public static SqlScalarExpression VisitBuiltinFunctionCall(MethodCallExpression throw new DocumentQueryException(string.Format(CultureInfo.CurrentCulture, ClientResources.MethodNotSupported, methodCallExpression.Method.Name)); } + /// + /// Checks if the method is MemoryExtensions.Contains (used by .NET 10+ for array.Contains()). + /// + private static bool IsMemoryExtensionsMethod(MethodCallExpression methodCallExpression) + { + return methodCallExpression.Method.DeclaringType == typeof(MemoryExtensions) + && methodCallExpression.Method.Name == "Contains"; + } + protected abstract SqlScalarExpression VisitExplicit(MethodCallExpression methodCallExpression, TranslationContext context); protected abstract SqlScalarExpression VisitImplicit(MethodCallExpression methodCallExpression, TranslationContext context); diff --git a/Microsoft.Azure.Cosmos/src/Linq/ConstantEvaluator.cs b/Microsoft.Azure.Cosmos/src/Linq/ConstantEvaluator.cs index 50b22bb15d..13e1554ebf 100644 --- a/Microsoft.Azure.Cosmos/src/Linq/ConstantEvaluator.cs +++ b/Microsoft.Azure.Cosmos/src/Linq/ConstantEvaluator.cs @@ -53,6 +53,21 @@ private static bool CanBeEvaluated(Expression expression) { return false; } + + // In .NET 10+, array.Contains() resolves to MemoryExtensions.Contains(ReadOnlySpan, T) + // which involves an op_Implicit conversion from T[] to ReadOnlySpan. + // ReadOnlySpan is a ref struct and cannot be boxed into Expression.Constant, + // so we must prevent evaluation of the op_Implicit argument. + // The Nominator processes bottom-up (children first), so blocking op_Implicit + // also prevents the parent MemoryExtensions.Contains from being nominated. + if (methodCallExpression.Method.Name == "op_Implicit" + && type != null + && type.IsGenericType + && (type.GetGenericTypeDefinition() == typeof(ReadOnlySpan<>) + || type.GetGenericTypeDefinition() == typeof(Span<>))) + { + return false; + } } if (expression.NodeType == ExpressionType.Constant && expression.Type == typeof(object)) 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..21bcd974aa --- /dev/null +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Linq/LinqMemoryExtensionsTests.cs @@ -0,0 +1,448 @@ +//------------------------------------------------------------ +// 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.Azure.Documents; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + /// + /// Tests for .NET 10 MemoryExtensions.Contains LINQ translation (Issue #5518). + /// + /// In .NET 10+, array.Contains(x) generates: + /// MemoryExtensions.Contains<T>(ReadOnlySpan<T>.op_Implicit(array), x) + /// + /// These tests validate each condition in our fix: + /// 1. ConstantEvaluator: MemoryExtensions.Contains must not be evaluated as a constant + /// 2. ConstantEvaluator: op_Implicit on ReadOnlySpan/Span must not be evaluated + /// 3. BuiltinFunctionVisitor: MemoryExtensions.Contains must route to ArrayBuiltinFunctions + /// 4. Unsupported MemoryExtensions methods must throw DocumentQueryException + /// + [TestClass] + public class LinqMemoryExtensionsTests + { + #region Helpers + + private static MethodInfo GetMemoryExtensionsContainsMethod() + { + return typeof(MemoryExtensions) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(m => m.Name == "Contains" && m.IsGenericMethod && m.GetParameters().Length == 2) + .Select(m => m.MakeGenericMethod(typeof(T))) + .FirstOrDefault(m => m.GetParameters()[0].ParameterType == typeof(ReadOnlySpan)); + } + + private static MethodInfo GetOpImplicitMethod() + { + return typeof(ReadOnlySpan) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .FirstOrDefault(m => m.Name == "op_Implicit" + && m.GetParameters().Length == 1 + && m.GetParameters()[0].ParameterType == typeof(T[])); + } + + /// + /// Builds the expression tree that .NET 10 generates for array.Contains(item): + /// MemoryExtensions.Contains<T>(ReadOnlySpan<T>.op_Implicit(array), item) + /// + private static MethodCallExpression BuildNet10ContainsExpression( + Expression arrayExpression, + Expression itemExpression) + { + MethodInfo containsMethod = GetMemoryExtensionsContainsMethod(); + MethodInfo opImplicit = GetOpImplicitMethod(); + + MethodCallExpression spanConversion = Expression.Call(opImplicit, arrayExpression); + return Expression.Call(containsMethod, spanConversion, itemExpression); + } + + /// + /// Gets an unsupported MemoryExtensions method (IndexOf) for negative testing. + /// + private static MethodInfo GetMemoryExtensionsIndexOfMethod() + { + return typeof(MemoryExtensions) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(m => m.Name == "IndexOf" && m.IsGenericMethod && m.GetParameters().Length == 2) + .Select(m => m.MakeGenericMethod(typeof(string))) + .FirstOrDefault(m => m.GetParameters()[0].ParameterType == typeof(ReadOnlySpan)); + } + + #endregion + + #region ConstantEvaluator — MemoryExtensions.Contains is not evaluated + + /// + /// ConstantEvaluator must NOT evaluate MemoryExtensions.Contains — it must be preserved + /// in the expression tree for the SQL translator to process. + /// + [TestMethod] + public void ConstantEvaluator_MemoryExtensionsContains_IsNotEvaluated() + { + MethodInfo opImplicit = GetOpImplicitMethod(); + if (opImplicit == null) + { + Assert.Inconclusive("ReadOnlySpan.op_Implicit not available on this runtime"); + return; + } + + string[] testArray = new[] { "a", "b", "c" }; + ConstantExpression arrayConst = Expression.Constant(testArray); + ParameterExpression paramX = Expression.Parameter(typeof(string), "x"); + + MethodCallExpression net10Contains = BuildNet10ContainsExpression(arrayConst, paramX); + Expression> lambda = Expression.Lambda>(net10Contains, paramX); + + // PartialEval must NOT throw — the fix prevents evaluation of the MemoryExtensions call + Expression result = ConstantEvaluator.PartialEval(lambda.Body); + + // The result should still be a MethodCallExpression (not collapsed to a constant) + Assert.IsInstanceOfType(result, typeof(MethodCallExpression), + "MemoryExtensions.Contains should be preserved as a method call, not evaluated to a constant"); + } + + /// + /// ConstantEvaluator must NOT evaluate op_Implicit on ReadOnlySpan because ref structs + /// cannot be boxed into Expression.Constant. + /// + [TestMethod] + public void ConstantEvaluator_OpImplicitOnReadOnlySpan_IsNotEvaluated() + { + MethodInfo opImplicit = GetOpImplicitMethod(); + if (opImplicit == null) + { + Assert.Inconclusive("ReadOnlySpan.op_Implicit not available on this runtime"); + return; + } + + string[] testArray = new[] { "a", "b" }; + ConstantExpression arrayConst = Expression.Constant(testArray); + MethodCallExpression opImplicitCall = Expression.Call(opImplicit, arrayConst); + + // PartialEval must NOT throw — if it tries to evaluate this, it will fail + // because ReadOnlySpan is a ref struct that cannot be stored in Expression.Constant + Expression result = ConstantEvaluator.PartialEval(opImplicitCall); + + // The op_Implicit call should be preserved (not collapsed) + Assert.IsInstanceOfType(result, typeof(MethodCallExpression), + "op_Implicit on ReadOnlySpan should be preserved, not evaluated"); + } + + /// + /// ConstantEvaluator must NOT evaluate op_Implicit on Span<T> either. + /// + [TestMethod] + public void ConstantEvaluator_OpImplicitOnSpan_IsNotEvaluated() + { + MethodInfo opImplicit = typeof(Span) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .FirstOrDefault(m => m.Name == "op_Implicit" + && m.GetParameters().Length == 1 + && m.GetParameters()[0].ParameterType == typeof(string[])); + + if (opImplicit == null) + { + Assert.Inconclusive("Span.op_Implicit not available on this runtime"); + return; + } + + string[] testArray = new[] { "a", "b" }; + ConstantExpression arrayConst = Expression.Constant(testArray); + MethodCallExpression opImplicitCall = Expression.Call(opImplicit, arrayConst); + + Expression result = ConstantEvaluator.PartialEval(opImplicitCall); + + Assert.IsInstanceOfType(result, typeof(MethodCallExpression), + "op_Implicit on Span should be preserved, not evaluated"); + } + + /// + /// Enumerable.Contains must continue to be excluded from evaluation (regression test). + /// + [TestMethod] + public void ConstantEvaluator_EnumerableContains_StillExcluded() + { + MethodInfo enumerableContains = typeof(Enumerable) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(m => m.Name == "Contains" && m.IsGenericMethod) + .Select(m => m.MakeGenericMethod(typeof(string))) + .First(m => m.GetParameters().Length == 2); + + string[] testArray = new[] { "a", "b" }; + ConstantExpression arrayExpr = Expression.Constant(testArray); + ParameterExpression paramX = Expression.Parameter(typeof(string), "x"); + MethodCallExpression containsCall = Expression.Call(enumerableContains, arrayExpr, paramX); + + Expression> lambda = Expression.Lambda>(containsCall, paramX); + + Expression result = ConstantEvaluator.PartialEval(lambda.Body); + + // Should be preserved as a method call for SQL translation + Assert.IsInstanceOfType(result, typeof(MethodCallExpression), + "Enumerable.Contains should be preserved for SQL translation"); + } + + /// + /// Non-Span op_Implicit (e.g., Decimal from int) should still be evaluable. + /// This ensures we don't over-block. + /// + [TestMethod] + public void ConstantEvaluator_NonSpanOpImplicit_IsStillEvaluated() + { + // Decimal.op_Implicit(int) - reliably available via reflection on all runtimes + MethodInfo opImplicit = typeof(decimal) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .First(m => m.Name == "op_Implicit" + && m.GetParameters().Length == 1 + && m.GetParameters()[0].ParameterType == typeof(int)); + + ConstantExpression intConst = Expression.Constant(42); + MethodCallExpression implicitCall = Expression.Call(opImplicit, intConst); + + Expression result = ConstantEvaluator.PartialEval(implicitCall); + + // This SHOULD be evaluated to a constant since decimal is not a ref struct + Assert.IsInstanceOfType(result, typeof(ConstantExpression), + "Non-Span op_Implicit should be evaluated to a constant"); + } + + #endregion + + #region BuiltinFunctionVisitor — Supported method: MemoryExtensions.Contains → SQL IN + + /// + /// End-to-end: MemoryExtensions.Contains with string[] produces correct SQL IN clause. + /// + [TestMethod] + public void Translate_MemoryExtensionsContains_StringArray_ProducesInClause() + { + MethodInfo opImplicit = GetOpImplicitMethod(); + if (opImplicit == null) + { + Assert.Inconclusive("ReadOnlySpan.op_Implicit not available on this runtime"); + return; + } + + string[] testArray = new[] { "a", "b", "c" }; + ConstantExpression arrayConst = Expression.Constant(testArray); + ParameterExpression paramX = Expression.Parameter(typeof(string), "x"); + MethodCallExpression net10Contains = BuildNet10ContainsExpression(arrayConst, paramX); + + string sql = SqlTranslator.TranslateExpression(net10Contains); + + Assert.IsNotNull(sql); + Assert.IsTrue(sql.Contains("IN"), $"Expected IN clause but got: {sql}"); + Assert.IsTrue(sql.Contains("\"a\""), $"Expected array element 'a' in SQL: {sql}"); + Assert.IsTrue(sql.Contains("\"b\""), $"Expected array element 'b' in SQL: {sql}"); + Assert.IsTrue(sql.Contains("\"c\""), $"Expected array element 'c' in SQL: {sql}"); + } + + /// + /// End-to-end: MemoryExtensions.Contains with int[] produces correct SQL IN clause. + /// + [TestMethod] + public void Translate_MemoryExtensionsContains_IntArray_ProducesInClause() + { + MethodInfo opImplicit = GetOpImplicitMethod(); + if (opImplicit == null) + { + Assert.Inconclusive("ReadOnlySpan.op_Implicit not available on this runtime"); + return; + } + + int[] testArray = new[] { 1, 2, 3 }; + ConstantExpression arrayConst = Expression.Constant(testArray); + ParameterExpression paramX = Expression.Parameter(typeof(int), "x"); + MethodCallExpression net10Contains = BuildNet10ContainsExpression(arrayConst, paramX); + + string sql = SqlTranslator.TranslateExpression(net10Contains); + + Assert.IsNotNull(sql); + Assert.IsTrue(sql.Contains("IN"), $"Expected IN clause but got: {sql}"); + Assert.IsTrue(sql.Contains("1"), $"Expected element '1' in SQL: {sql}"); + Assert.IsTrue(sql.Contains("3"), $"Expected element '3' in SQL: {sql}"); + } + + /// + /// End-to-end: MemoryExtensions.Contains with Guid[] produces correct SQL IN clause. + /// + [TestMethod] + public void Translate_MemoryExtensionsContains_GuidArray_ProducesInClause() + { + MethodInfo opImplicit = GetOpImplicitMethod(); + if (opImplicit == null) + { + Assert.Inconclusive("ReadOnlySpan.op_Implicit not available on this runtime"); + return; + } + + Guid guid1 = Guid.Parse("11111111-1111-1111-1111-111111111111"); + Guid guid2 = Guid.Parse("22222222-2222-2222-2222-222222222222"); + Guid[] testArray = new[] { guid1, guid2 }; + ConstantExpression arrayConst = Expression.Constant(testArray); + ParameterExpression paramX = Expression.Parameter(typeof(Guid), "x"); + MethodCallExpression net10Contains = BuildNet10ContainsExpression(arrayConst, paramX); + + string sql = SqlTranslator.TranslateExpression(net10Contains); + + Assert.IsNotNull(sql); + Assert.IsTrue(sql.Contains("IN"), $"Expected IN clause but got: {sql}"); + Assert.IsTrue(sql.Contains("11111111-1111-1111-1111-111111111111"), + $"Expected guid1 in SQL: {sql}"); + } + + /// + /// Regression: Enumerable.Contains still produces correct SQL IN clause. + /// + [TestMethod] + public void Translate_EnumerableContains_StillProducesInClause() + { + MethodInfo enumerableContains = typeof(Enumerable) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(m => m.Name == "Contains" && m.IsGenericMethod) + .Select(m => m.MakeGenericMethod(typeof(string))) + .First(m => m.GetParameters().Length == 2); + + string[] testArray = new[] { "x", "y" }; + ConstantExpression arrayExpr = Expression.Constant(testArray); + ParameterExpression paramX = Expression.Parameter(typeof(string), "x"); + MethodCallExpression containsCall = Expression.Call(enumerableContains, arrayExpr, paramX); + + Expression> lambda = Expression.Lambda>(containsCall, paramX); + string sql = SqlTranslator.TranslateExpression(lambda.Body); + + Assert.IsNotNull(sql); + Assert.IsTrue(sql.Contains("IN"), $"Expected IN clause but got: {sql}"); + Assert.IsTrue(sql.Contains("\"x\""), $"Expected 'x' in SQL: {sql}"); + Assert.IsTrue(sql.Contains("\"y\""), $"Expected 'y' in SQL: {sql}"); + } + + #endregion + + #region BuiltinFunctionVisitor — Unsupported MemoryExtensions methods throw + + /// + /// MemoryExtensions.IndexOf is NOT supported and must throw DocumentQueryException. + /// This validates that we only support Contains — not arbitrary MemoryExtensions methods. + /// + [TestMethod] + public void Translate_MemoryExtensionsIndexOf_ThrowsDocumentQueryException() + { + MethodInfo indexOfMethod = GetMemoryExtensionsIndexOfMethod(); + MethodInfo opImplicit = GetOpImplicitMethod(); + + if (indexOfMethod == null || opImplicit == null) + { + Assert.Inconclusive("Required methods not available on this runtime"); + return; + } + + string[] testArray = new[] { "a", "b" }; + ConstantExpression arrayConst = Expression.Constant(testArray); + ParameterExpression paramX = Expression.Parameter(typeof(string), "x"); + MethodCallExpression spanConversion = Expression.Call(opImplicit, arrayConst); + MethodCallExpression indexOfCall = Expression.Call(indexOfMethod, spanConversion, paramX); + + DocumentQueryException exception = Assert.ThrowsException( + () => SqlTranslator.TranslateExpression(indexOfCall), + "Unsupported MemoryExtensions methods should throw DocumentQueryException"); + + Assert.IsTrue(exception.Message.Contains("IndexOf"), + $"Error message should mention the unsupported method name. Got: {exception.Message}"); + } + + /// + /// MemoryExtensions.SequenceEqual is NOT supported and must throw DocumentQueryException. + /// + [TestMethod] + public void Translate_MemoryExtensionsSequenceEqual_ThrowsDocumentQueryException() + { + MethodInfo sequenceEqualMethod = typeof(MemoryExtensions) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(m => m.Name == "SequenceEqual" && m.IsGenericMethod && m.GetParameters().Length == 2) + .Select(m => m.MakeGenericMethod(typeof(string))) + .FirstOrDefault(); + + MethodInfo opImplicit = GetOpImplicitMethod(); + + if (sequenceEqualMethod == null || opImplicit == null) + { + Assert.Inconclusive("Required methods not available on this runtime"); + return; + } + + string[] testArray1 = new[] { "a", "b" }; + string[] testArray2 = new[] { "a", "b" }; + ConstantExpression arr1Const = Expression.Constant(testArray1); + ConstantExpression arr2Const = Expression.Constant(testArray2); + MethodCallExpression span1 = Expression.Call(opImplicit, arr1Const); + MethodCallExpression span2 = Expression.Call(opImplicit, arr2Const); + + // SequenceEqual takes two ReadOnlySpan args — may not match the method signature + // that takes (ReadOnlySpan, ReadOnlySpan). Find the right overload. + MethodInfo seqEqual2Spans = typeof(MemoryExtensions) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(m => m.Name == "SequenceEqual" && m.IsGenericMethod) + .Select(m => m.MakeGenericMethod(typeof(string))) + .FirstOrDefault(m => + { + ParameterInfo[] p = m.GetParameters(); + return p.Length == 2 + && p[0].ParameterType == typeof(ReadOnlySpan) + && p[1].ParameterType == typeof(ReadOnlySpan); + }); + + if (seqEqual2Spans == null) + { + Assert.Inconclusive("MemoryExtensions.SequenceEqual(ReadOnlySpan, ReadOnlySpan) not found"); + return; + } + + MethodCallExpression seqEqualCall = Expression.Call(seqEqual2Spans, span1, span2); + + Assert.ThrowsException( + () => SqlTranslator.TranslateExpression(seqEqualCall), + "Unsupported MemoryExtensions.SequenceEqual should throw DocumentQueryException"); + } + + #endregion + + #region ConstantEvaluator — Unsupported MemoryExtensions methods (non-Contains) are still evaluable + + /// + /// MemoryExtensions methods other than Contains should NOT be blocked by ConstantEvaluator. + /// They are not our concern — if they reach the translator, they'll get an appropriate error. + /// If they can be evaluated (e.g., in a sub-expression), the evaluator should allow it. + /// + /// Note: In practice, these will fail evaluation anyway due to the op_Implicit on Span, + /// but the ConstantEvaluator exclusion is specifically scoped to Contains only. + /// + [TestMethod] + public void ConstantEvaluator_MemoryExtensionsNonContains_NotExplicitlyExcluded() + { + MethodInfo indexOfMethod = GetMemoryExtensionsIndexOfMethod(); + if (indexOfMethod == null) + { + Assert.Inconclusive("MemoryExtensions.IndexOf not available"); + return; + } + + // The MemoryExtensions.Contains check uses: type == typeof(MemoryExtensions) && Name == "Contains" + // IndexOf should NOT match this condition + Assert.AreEqual(typeof(MemoryExtensions), indexOfMethod.DeclaringType); + Assert.AreNotEqual("Contains", indexOfMethod.Name); + + // The method is "not excluded" by our MemoryExtensions check. + // It will still be blocked by the op_Implicit check (since its argument is a Span), + // but the MemoryExtensions-specific exclusion doesn't cover it. + // This is intentional — we only explicitly support Contains. + } + + #endregion + } +}