diff --git a/src/Nest.OData/ODataFilterExtensions.cs b/src/Nest.OData/ODataFilterExtensions.cs index 1e94eb7..eb13013 100644 --- a/src/Nest.OData/ODataFilterExtensions.cs +++ b/src/Nest.OData/ODataFilterExtensions.cs @@ -50,6 +50,11 @@ public static QueryContainer ToQueryContainer(this FilterQueryOption filter, ODa internal static QueryContainer TranslateExpression(QueryNode node, ODataExpressionContext context = null) { + if (ShouldOptimizeOrOperation(node)) + { + return OptimizeIdenticalFunctionCalls(node as BinaryOperatorNode, context); + } + return node.Kind switch { QueryNodeKind.Any => TranslateAnyNode(node as AnyNode, context), @@ -58,12 +63,78 @@ internal static QueryContainer TranslateExpression(QueryNode node, ODataExpressi QueryNodeKind.BinaryOperator => TranslateOperatorNode(node as BinaryOperatorNode, context), QueryNodeKind.SingleValueFunctionCall => TranslateFunctionCallNode(node as SingleValueFunctionCallNode, context), QueryNodeKind.Convert => TranslateExpression(((ConvertNode)node).Source, context), + QueryNodeKind.UnaryOperator => TranslateUnaryOperatorNode(node as UnaryOperatorNode, context), QueryNodeKind.SingleValuePropertyAccess => null, QueryNodeKind.Constant => null, _ => throw new NotImplementedException($"Unsupported node type: {node.Kind}"), }; } + private static bool ShouldOptimizeOrOperation(QueryNode node) + { + return node is BinaryOperatorNode binaryNode && + binaryNode.OperatorKind == BinaryOperatorKind.Or && + binaryNode.Left is SingleValueFunctionCallNode && + binaryNode.Right is SingleValueFunctionCallNode; + } + + private static QueryContainer OptimizeIdenticalFunctionCalls(BinaryOperatorNode node, ODataExpressionContext context) + { + var leftFunc = node.Left as SingleValueFunctionCallNode; + var rightFunc = node.Right as SingleValueFunctionCallNode; + + var functionCalls = new[] + { + (Node: leftFunc, IsNested: IsNestedFunctionCall(leftFunc)), + (Node: rightFunc, IsNested: IsNestedFunctionCall(rightFunc)) + }; + + if (AreFunctionCallsIdentical(functionCalls[0], functionCalls[1])) + { + return TranslateExpression(node.Left, context); + } + + return TranslateExpression(node.Right, context); + } + + private static bool AreFunctionCallsIdentical((SingleValueFunctionCallNode Node, bool IsNested) first, (SingleValueFunctionCallNode Node, bool IsNested) other) + { + if (HasIdenticalFunctionSignature(first.Node, other.Node) == false) + { + return false; + } + + if (HasIdenticalParameters(first.Node, other.Node) == false) + { + return false; + } + + if (first.IsNested != other.IsNested) + { + return false; + } + + return true; + } + + private static bool HasIdenticalFunctionSignature(SingleValueFunctionCallNode first, SingleValueFunctionCallNode other) + { + return string.Equals(first.Name, other.Name, StringComparison.OrdinalIgnoreCase) && first.Parameters.Count() == other.Parameters.Count(); + } + + private static bool HasIdenticalParameters(SingleValueFunctionCallNode first, SingleValueFunctionCallNode other) + { + if (first.Parameters.First().ToString() != other.Parameters.First().ToString()) + { + return false; + } + + var firstValue = ExtractValue(first.Parameters.Last())?.ToString(); + var otherValue = ExtractValue(other.Parameters.Last())?.ToString(); + + return string.Equals(firstValue, otherValue); + } + private static QueryContainer TranslateAnyNode(AnyNode node, ODataExpressionContext context = null) { var fullyQualifiedFieldName = ODataHelpers.ExtractFullyQualifiedFieldName(node.Source, context); @@ -212,6 +283,7 @@ private static QueryContainer TranslateFunctionCallNode(SingleValueFunctionCallN private static QueryContainer TranslateOrOperations(BinaryOperatorNode node, ODataExpressionContext context = null) { var queries = new List(); + var functionCalls = new List<(SingleValueFunctionCallNode Node, bool IsNested)>(); void Collect(QueryNode queryNode) { @@ -222,13 +294,56 @@ void Collect(QueryNode queryNode) } else { - queries.Add(TranslateExpression(queryNode, context)); + if (queryNode is SingleValueFunctionCallNode funcNode) + { + var isNested = IsNestedFunctionCall(funcNode); + functionCalls.Add((funcNode, isNested)); + } + + var query = TranslateExpression(queryNode, context); + if (query != null) + { + queries.Add(query); + } } } Collect(node); - return new BoolQuery { Should = queries, MinimumShouldMatch = 1 }; + if (queries.Any() == false) + { + return null; + } + + return OptimizeOrQueries(queries, functionCalls); + } + + private static bool IsNestedFunctionCall(SingleValueFunctionCallNode funcNode) + { + return funcNode.Parameters.First() is SingleValuePropertyAccessNode propNode && ODataHelpers.IsNavigationNode(propNode.Source.Kind); + } + + private static QueryContainer OptimizeOrQueries(List queries, List<(SingleValueFunctionCallNode Node, bool IsNested)> functionCalls) + { + if (CanOptimizeFunctionCalls(functionCalls, queries.Count) == false) + { + return new BoolQuery { Should = queries, MinimumShouldMatch = 1 }; + } + + var first = functionCalls[0]; + + return AreAllFunctionCallsIdentical(functionCalls, first) ? queries[0] : new BoolQuery { Should = queries, MinimumShouldMatch = 1 }; + } + + private static bool CanOptimizeFunctionCalls(List<(SingleValueFunctionCallNode Node, bool IsNested)> functionCalls, int queryCount) + { + return functionCalls.Count > 0 && functionCalls.Count == queryCount; + } + + private static bool AreAllFunctionCallsIdentical(List<(SingleValueFunctionCallNode Node, bool IsNested)> functionCalls, + (SingleValueFunctionCallNode Node, bool IsNested) first) + { + return functionCalls.All(f => AreFunctionCallsIdentical(first, f)); } private static QueryContainer TranslateAndOperations(BinaryOperatorNode node, ODataExpressionContext context = null) @@ -250,6 +365,17 @@ void Collect(QueryNode queryNode) Collect(node); + if (queries.Any() == false) + { + return null; + } + + // If we have a single query from an AND operation to maintain the expected structure. + if (queries.Count == 1) + { + return new BoolQuery { Must = queries }; + } + return new BoolQuery { Must = queries }; } @@ -319,7 +445,7 @@ private static string ExtractStringValue(QueryNode node) { if (constantNode.Value is DateTime dateTime) { - return dateTime.ToString("o"); + return dateTime.ToString("o"); } else if (constantNode.Value is DateTimeOffset dateTimeOffset) { @@ -337,5 +463,26 @@ private static string ExtractStringValue(QueryNode node) throw new NotImplementedException("Complex values are not supported yet."); } + + + private static QueryContainer TranslateUnaryOperatorNode(UnaryOperatorNode node, ODataExpressionContext context) + { + if (node.OperatorKind != UnaryOperatorKind.Not) + { + throw new NotImplementedException($"Unsupported unary operator: {node.OperatorKind}"); + } + + var operandQuery = TranslateExpression(node.Operand, context); + + if (operandQuery == null) + { + return null; + } + + return new BoolQuery + { + MustNot = new[] { operandQuery } + }; + } } } diff --git a/test/Nest.OData.Tests/FilterFunctionTests.cs b/test/Nest.OData.Tests/FilterFunctionTests.cs index e66d991..a21d5f4 100644 --- a/test/Nest.OData.Tests/FilterFunctionTests.cs +++ b/test/Nest.OData.Tests/FilterFunctionTests.cs @@ -117,5 +117,171 @@ public void ContainsToLowerFunction() Assert.True(JToken.DeepEquals(expectedJObject, actualJObject), "Expected and actual JSON do not match."); } + + [Fact] + public void MultipleIdenticalFunctionCalls() + { + var queryOptions = "$filter=(contains(Category,'Goods')) or (contains(Category,'Goods')) or (contains(Category,'Goods'))".GetODataQueryOptions(); + + var elasticQuery = queryOptions.ToElasticQuery(); + + Assert.NotNull(elasticQuery); + + var queryJson = elasticQuery.ToJson(); + + var expectedJson = @" + { + ""query"": { + ""wildcard"": { + ""Category"": { + ""value"": ""*goods*"" + } + } + } + }"; + + var actualJObject = JObject.Parse(queryJson); + var expectedJObject = JObject.Parse(expectedJson); + + Assert.True(JToken.DeepEquals(expectedJObject, actualJObject), "Expected and actual JSON do not match."); + } + + [Fact] + public void MultipleDifferentFunctionCalls() + { + var queryOptions = "$filter=(contains(Category,'Goods')) or (contains(Category,'Food')) or (contains(Name,'Merchandise'))".GetODataQueryOptions(); + + var elasticQuery = queryOptions.ToElasticQuery(); + + Assert.NotNull(elasticQuery); + + var queryJson = elasticQuery.ToJson(); + + var expectedJson = @" + { + ""query"": { + ""bool"": { + ""minimum_should_match"": 1, + ""should"": [ + { + ""wildcard"": { + ""Category"": { + ""value"": ""*goods*"" + } + } + }, + { + ""wildcard"": { + ""Category"": { + ""value"": ""*food*"" + } + } + }, + { + ""wildcard"": { + ""Name"": { + ""value"": ""*merchandise*"" + } + } + } + ] + } + } + }"; + + var actualJObject = JObject.Parse(queryJson); + var expectedJObject = JObject.Parse(expectedJson); + + Assert.True(JToken.DeepEquals(expectedJObject, actualJObject), "Expected and actual JSON do not match."); + } + + [Fact] + public void MultipleOperatorsFunctionCall() + { + var queryOptions = "$filter=(contains(Category,'Food') and contains(Category,'Goods')) or contains(Name,'Merchandise')".GetODataQueryOptions(); + + var elasticQuery = queryOptions.ToElasticQuery(); + + Assert.NotNull(elasticQuery); + + var queryJson = elasticQuery.ToJson(); + + var expectedJson = @" + { + ""query"": { + ""bool"": { + ""minimum_should_match"": 1, + ""should"": [ + { + ""bool"": { + ""must"": [ + { + ""wildcard"": { + ""Category"": { + ""value"": ""*food*"" + } + } + }, + { + ""wildcard"": { + ""Category"": { + ""value"": ""*goods*"" + } + } + } + ] + } + }, + { + ""wildcard"": { + ""Name"": { + ""value"": ""*merchandise*"" + } + } + } + ], + } + } + }"; + + var actualJObject = JObject.Parse(queryJson); + var expectedJObject = JObject.Parse(expectedJson); + + Assert.True(JToken.DeepEquals(expectedJObject, actualJObject), "Expected and actual JSON do not match."); + } + + [Fact] + public void NotFunction() + { + var queryOptions = "$filter=not(Category eq 'Goods')".GetODataQueryOptions(); + + var elasticQuery = queryOptions.ToElasticQuery(); + + Assert.NotNull(elasticQuery); + + var queryJson = elasticQuery.ToJson(); + + var expectedJson = @" + { + ""query"": { + ""bool"": { + ""must_not"": [ + { + ""term"": { + ""Category"": { + ""value"": ""goods"" + } + } + } + ] + } + } + }"; + + var actualJObject = JObject.Parse(queryJson); + var expectedJObject = JObject.Parse(expectedJson); + + Assert.True(JToken.DeepEquals(expectedJObject, actualJObject), "Expected and actual JSON do not match."); + } } } diff --git a/test/Nest.OData.Tests/FilterOperatorTests.cs b/test/Nest.OData.Tests/FilterOperatorTests.cs index c2ee793..69d26d2 100644 --- a/test/Nest.OData.Tests/FilterOperatorTests.cs +++ b/test/Nest.OData.Tests/FilterOperatorTests.cs @@ -391,5 +391,39 @@ public void EqualsGuid() Assert.True(JToken.DeepEquals(expectedJObject, actualJObject), "Expected and actual JSON do not match."); } + + [Fact] + public void NotOperator() + { + var queryOptions = "$filter=not contains(Category, 'Goods')".GetODataQueryOptions(); + + var elasticQuery = queryOptions.ToElasticQuery(); + + Assert.NotNull(elasticQuery); + + var queryJson = elasticQuery.ToJson(); + + var expectedJson = @" + { + ""query"": { + ""bool"": { + ""must_not"": [ + { + ""wildcard"": { + ""Category"": { + ""value"": ""*goods*"" + } + } + } + ] + } + } + }"; + + var actualJObject = JObject.Parse(queryJson); + var expectedJObject = JObject.Parse(expectedJson); + + Assert.True(JToken.DeepEquals(expectedJObject, actualJObject), "Expected and actual JSON do not match."); + } } }