From 786db819279f456875d429e94fd018469faf280a Mon Sep 17 00:00:00 2001 From: Ben Robinson Date: Fri, 31 Jan 2025 11:41:37 +0000 Subject: [PATCH] Added ArrayContains to CosmosLinqExtensions to allow partial matching The `Array_Contains` funtion CosmosDB Sql has a 3rd parameter which allows it to do a partial match on the given item. This is unable to be called with the built in Linq `array.Contains(item)` extension methods. This adds this adds an explicit mapping to this function to allow it to be called in Linq like this: `documents.Where(document => document.ObjectArray.ArrayContains(new { Name = "abc" }, true))` --- .../BuiltinFunctions/ArrayBuiltinFunctions.cs | 29 +++++- .../BuiltinFunctionVisitor.cs | 7 +- .../src/Linq/CosmosLinqExtensions.cs | 30 ++++++ ...Tests.TestArrayContainsBuiltinFunction.xml | 94 +++++++++++++++++++ .../Linq/LinqTranslationBaselineTests.cs | 26 +++++ ...icrosoft.Azure.Cosmos.EmulatorTests.csproj | 3 + .../Contracts/DotNetSDKAPI.json | 7 ++ 7 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/BaselineTest/TestBaseline/LinqTranslationBaselineTests.TestArrayContainsBuiltinFunction.xml diff --git a/Microsoft.Azure.Cosmos/src/Linq/BuiltinFunctions/ArrayBuiltinFunctions.cs b/Microsoft.Azure.Cosmos/src/Linq/BuiltinFunctions/ArrayBuiltinFunctions.cs index d96ac56a22..19ff8a7701 100644 --- a/Microsoft.Azure.Cosmos/src/Linq/BuiltinFunctions/ArrayBuiltinFunctions.cs +++ b/Microsoft.Azure.Cosmos/src/Linq/BuiltinFunctions/ArrayBuiltinFunctions.cs @@ -42,10 +42,13 @@ public ArrayContainsVisitor() { } + public bool UsePartialMatchParameter { get; set; } + protected override SqlScalarExpression VisitImplicit(MethodCallExpression methodCallExpression, TranslationContext context) { Expression searchList = null; Expression searchExpression = null; + Expression partialMatchExpression = null; // If non static Contains if (methodCallExpression.Arguments.Count == 1) @@ -59,6 +62,13 @@ protected override SqlScalarExpression VisitImplicit(MethodCallExpression method searchList = methodCallExpression.Arguments[0]; searchExpression = methodCallExpression.Arguments[1]; } + // if CosmosLinqExtensions.ArrayContains extension method which includes partial match parameter + else if (this.UsePartialMatchParameter && methodCallExpression.Arguments.Count == 3) + { + searchList = methodCallExpression.Arguments[0]; + searchExpression = methodCallExpression.Arguments[1]; + partialMatchExpression = methodCallExpression.Arguments[2]; + } if (searchList == null || searchExpression == null) { @@ -72,7 +82,20 @@ protected override SqlScalarExpression VisitImplicit(MethodCallExpression method SqlScalarExpression array = ExpressionToSql.VisitScalarExpression(searchList, context); SqlScalarExpression expression = ExpressionToSql.VisitScalarExpression(searchExpression, context); - return SqlFunctionCallScalarExpression.CreateBuiltin("ARRAY_CONTAINS", array, expression); + + SqlScalarExpression[] arrayContainsArgs; + + if (partialMatchExpression is null) + { + arrayContainsArgs = new[] { array, expression }; + } + else + { + SqlScalarExpression partialMatch = ExpressionToSql.VisitScalarExpression(partialMatchExpression, context); + arrayContainsArgs = new[] { array, expression, partialMatch }; + } + + return SqlFunctionCallScalarExpression.CreateBuiltin("ARRAY_CONTAINS", arrayContainsArgs); } private SqlScalarExpression VisitIN(Expression expression, ConstantExpression constantExpressionList, TranslationContext context) @@ -177,6 +200,10 @@ static ArrayBuiltinFunctions() { "ToList", new ArrayToArrayVisitor() + }, + { + nameof(CosmosLinqExtensions.ArrayContains), + new ArrayContainsVisitor() { UsePartialMatchParameter = true } } }; } diff --git a/Microsoft.Azure.Cosmos/src/Linq/BuiltinFunctions/BuiltinFunctionVisitor.cs b/Microsoft.Azure.Cosmos/src/Linq/BuiltinFunctions/BuiltinFunctionVisitor.cs index 82f427c206..d4083fe6a0 100644 --- a/Microsoft.Azure.Cosmos/src/Linq/BuiltinFunctions/BuiltinFunctionVisitor.cs +++ b/Microsoft.Azure.Cosmos/src/Linq/BuiltinFunctions/BuiltinFunctionVisitor.cs @@ -59,7 +59,12 @@ public static SqlScalarExpression VisitBuiltinFunctionCall(MethodCallExpression if (methodCallExpression.Method.Name == nameof(CosmosLinqExtensions.DocumentId)) { return OtherBuiltinSystemFunctions.Visit(methodCallExpression, context); - } + } + + if (methodCallExpression.Method.Name == nameof(CosmosLinqExtensions.ArrayContains)) + { + return ArrayBuiltinFunctions.Visit(methodCallExpression, context); + } return TypeCheckFunctions.Visit(methodCallExpression, context); } diff --git a/Microsoft.Azure.Cosmos/src/Linq/CosmosLinqExtensions.cs b/Microsoft.Azure.Cosmos/src/Linq/CosmosLinqExtensions.cs index a45b282d15..ba962bf3de 100644 --- a/Microsoft.Azure.Cosmos/src/Linq/CosmosLinqExtensions.cs +++ b/Microsoft.Azure.Cosmos/src/Linq/CosmosLinqExtensions.cs @@ -5,6 +5,7 @@ namespace Microsoft.Azure.Cosmos.Linq { using System; + using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -237,6 +238,35 @@ public static bool RegexMatch(this object obj, string regularExpression, string throw new NotImplementedException(ClientResources.TypeCheckExtensionFunctionsNotImplemented); } + /// + /// Returns a boolean indicating whether the array contains the specified value. + /// You can check for a partial or full match of an object by using a boolean expression within the function. + /// For more information, see https://learn.microsoft.com/en-gb/azure/cosmos-db/nosql/query/array-contains. + /// This method is to be used in LINQ expressions only and will be evaluated on server. + /// There's no implementation provided in the client library. + /// + /// + /// The value to search within the array. + /// Indicating whether the search should check for a partial match (true) or a full match (false). + /// Returns true if the array array contains the specified value; otherwise, false. + /// + /// + /// document.Namess.ArrayContains(, )); + /// // To do a partial match on an array of objects, pass in an anonymous object set partialMatch to true + /// var matched = documents.Where(document => document.ObjectArray.ArrayContains(new { Name = }, true)); + /// ]]> + /// + /// + public static bool ArrayContains(this IEnumerable obj, object itemToMatch, bool partialMatch) + { + // The signature for this is not generic so the user can pass in anonymous type for the item to match + // e.g documents.Where(document => document.FooItems.ArrayContains(new { Name = "Bar" }, true) + // partialMatch could have a default values (bool partialMatch = false) but those are not valid in expressions + // (see error CS0854) and this method will only be used in expressions, so not point adding it + throw new NotImplementedException(ClientResources.TypeCheckExtensionFunctionsNotImplemented); + } + /// /// This method generate query definition from LINQ query. /// diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/BaselineTest/TestBaseline/LinqTranslationBaselineTests.TestArrayContainsBuiltinFunction.xml b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/BaselineTest/TestBaseline/LinqTranslationBaselineTests.TestArrayContainsBuiltinFunction.xml new file mode 100644 index 0000000000..ea280f6550 --- /dev/null +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/BaselineTest/TestBaseline/LinqTranslationBaselineTests.TestArrayContainsBuiltinFunction.xml @@ -0,0 +1,94 @@ + + + + + doc.ArrayField.ArrayContains(Convert(1, Object), True))]]> + + + + + + + + + doc.ArrayField.ArrayContains(Convert(1, Object), True))]]> + + + + + + + + + doc.ObjectArrayField.ArrayContains(new AnonymousType(Field = "abc"), True))]]> + + + + + + + + + doc.ObjectArrayField.ArrayContains(new AnonymousType(Field = "abc"), True))]]> + + + + + + + + + doc.ArrayField.ArrayContains(Convert(1, Object), False))]]> + + + + + + + + + doc.ArrayField.ArrayContains(Convert(1, Object), False))]]> + + + + + + + + + doc.ObjectArrayField.ArrayContains(new AnonymousType(Field = "abc"), False))]]> + + + + + + + + + doc.ObjectArrayField.ArrayContains(new AnonymousType(Field = "abc"), False))]]> + + + + + + \ No newline at end of file diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/Linq/LinqTranslationBaselineTests.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/Linq/LinqTranslationBaselineTests.cs index e53ec6a9f6..dcc48fa420 100644 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/Linq/LinqTranslationBaselineTests.cs +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/Linq/LinqTranslationBaselineTests.cs @@ -117,6 +117,7 @@ internal class DataObject : LinqTestObject #pragma warning disable CS0649 // Field is never assigned to, and will always have its default value false public bool BooleanField; public SimpleObject ObjectField = new SimpleObject(); + public SimpleObject[] ObjectArrayField = new SimpleObject[0]; public Guid GuidField; #pragma warning restore // Field is never assigned to, and will always have its default value false @@ -346,6 +347,31 @@ public void TestRegexMatchFunction() this.ExecuteTestSuite(inputs); } + [TestMethod] + public void TestArrayContainsBuiltinFunction() + { + // Similar to the type checking function, Array_Contains are not supported client side. + // Therefore these methods are verified with baseline only. + List data = new List(); + IOrderedQueryable query = testContainer.GetItemLinqQueryable(allowSynchronousQueryExecution: true); + Func> getQuery = useQuery => useQuery ? query : data.AsQueryable(); + + List inputs = new List + { + new LinqTestInput("ArrayContains in Select clause with int value and match partial true", b => getQuery(b).Select(doc => doc.ArrayField.ArrayContains(1, true))), + new LinqTestInput("ArrayContains in Filter clause with int value and match partial true", b => getQuery(b).Where(doc => doc.ArrayField.ArrayContains(1, true))), + new LinqTestInput("ArrayContains in Select clause with object value and match partial true", b => getQuery(b).Select(doc => doc.ObjectArrayField.ArrayContains(new { Field = "abc" }, true))), + new LinqTestInput("ArrayContains in Filter clause with object value and match partial true", b => getQuery(b).Where(doc => doc.ObjectArrayField.ArrayContains(new { Field = "abc" }, true))), + + new LinqTestInput("ArrayContains in Select clause with int value and match partial false", b => getQuery(b).Select(doc => doc.ArrayField.ArrayContains(1, false))), + new LinqTestInput("ArrayContains in Filter clause with int value and match partial false", b => getQuery(b).Where(doc => doc.ArrayField.ArrayContains(1, false))), + new LinqTestInput("ArrayContains in Select clause with object value and match partial false", b => getQuery(b).Select(doc => doc.ObjectArrayField.ArrayContains(new { Field = "abc" }, false))), + new LinqTestInput("ArrayContains in Filter clause with object value and match partial false", b => getQuery(b).Where(doc => doc.ObjectArrayField.ArrayContains(new { Field = "abc" }, false))), + }; + + this.ExecuteTestSuite(inputs); + } + [TestMethod] public void TestMemberInitializer() { diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/Microsoft.Azure.Cosmos.EmulatorTests.csproj b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/Microsoft.Azure.Cosmos.EmulatorTests.csproj index edcd384b97..ee56131f01 100644 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/Microsoft.Azure.Cosmos.EmulatorTests.csproj +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/Microsoft.Azure.Cosmos.EmulatorTests.csproj @@ -112,6 +112,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Contracts/DotNetSDKAPI.json b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Contracts/DotNetSDKAPI.json index e1118ce863..691210b689 100644 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Contracts/DotNetSDKAPI.json +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Contracts/DotNetSDKAPI.json @@ -6680,6 +6680,13 @@ "Microsoft.Azure.Cosmos.Linq.CosmosLinqExtensions;System.Object;IsAbstract:True;IsSealed:True;IsInterface:False;IsEnum:False;IsClass:True;IsValueType:False;IsNested:False;IsGenericType:False;IsSerializable:False": { "Subclasses": {}, "Members": { + "Boolean ArrayContains(System.Collections.IEnumerable, System.Object, Boolean)[System.Runtime.CompilerServices.ExtensionAttribute()]": { + "Type": "Method", + "Attributes": [ + "ExtensionAttribute" + ], + "MethodInfo": "Boolean ArrayContains(System.Collections.IEnumerable, System.Object, Boolean);IsAbstract:False;IsStatic:True;IsVirtual:False;IsGenericMethod:False;IsConstructor:False;IsFinal:False;" + }, "Boolean IsArray(System.Object)[System.Runtime.CompilerServices.ExtensionAttribute()]": { "Type": "Method", "Attributes": [