Skip to content

feat (ODataFilterExtensions): adds support and unit test for the not() function #21

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 150 additions & 3 deletions src/Nest.OData/ODataFilterExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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);
Expand Down Expand Up @@ -212,6 +283,7 @@ private static QueryContainer TranslateFunctionCallNode(SingleValueFunctionCallN
private static QueryContainer TranslateOrOperations(BinaryOperatorNode node, ODataExpressionContext context = null)
{
var queries = new List<QueryContainer>();
var functionCalls = new List<(SingleValueFunctionCallNode Node, bool IsNested)>();

void Collect(QueryNode queryNode)
{
Expand All @@ -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<QueryContainer> 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)
Expand All @@ -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 };
}

Expand Down Expand Up @@ -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)
{
Expand All @@ -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 }
};
}
}
}
166 changes: 166 additions & 0 deletions test/Nest.OData.Tests/FilterFunctionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Product>();

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<Product>();

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<Product>();

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<Product>();

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.");
}
}
}
Loading
Loading