From 88f5cbe7c5f14360bfd6351c0c3198e957d6b49e Mon Sep 17 00:00:00 2001 From: Milos Date: Tue, 29 May 2018 12:25:41 +0200 Subject: [PATCH 1/2] Add IN filter for array searching --- .../Extensions/IQueryableExtensions.cs | 107 +++++++++++++----- .../Internal/Query/FilterOperations.cs | 3 +- src/JsonApiDotNetCore/Internal/TypeHelper.cs | 24 ++++ src/JsonApiDotNetCore/Services/QueryParser.cs | 47 +++++--- .../Acceptance/Spec/AttributeFilterTests.cs | 71 ++++++++++++ 5 files changed, 208 insertions(+), 44 deletions(-) diff --git a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs index 79eda6c598..c392da2b93 100644 --- a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; +using System.Reflection; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Services; @@ -101,21 +102,30 @@ public static IQueryable Filter(this IQueryable sourc try { - // convert the incoming value to the target value type - // "1" -> 1 - var convertedValue = TypeHelper.ConvertType(filterQuery.PropertyValue, property.PropertyType); - // {model} - var parameter = Expression.Parameter(concreteType, "model"); - // {model.Id} - var left = Expression.PropertyOrField(parameter, property.Name); - // {1} - var right = Expression.Constant(convertedValue, property.PropertyType); - - var body = GetFilterExpressionLambda(left, right, filterQuery.FilterOperation); - - var lambda = Expression.Lambda>(body, parameter); - - return source.Where(lambda); + if (filterQuery.FilterOperation == FilterOperations.@in ) + { + string[] propertyValues = filterQuery.PropertyValue.Split(','); + var lambdaIn = ArrayContainsPredicate(propertyValues, property.Name); + + return source.Where(lambdaIn); + } + else + { // convert the incoming value to the target value type + // "1" -> 1 + var convertedValue = TypeHelper.ConvertType(filterQuery.PropertyValue, property.PropertyType); + // {model} + var parameter = Expression.Parameter(concreteType, "model"); + // {model.Id} + var left = Expression.PropertyOrField(parameter, property.Name); + // {1} + var right = Expression.Constant(convertedValue, property.PropertyType); + + var body = GetFilterExpressionLambda(left, right, filterQuery.FilterOperation); + + var lambda = Expression.Lambda>(body, parameter); + + return source.Where(lambda); + } } catch (FormatException) { @@ -140,26 +150,36 @@ public static IQueryable Filter(this IQueryable sourc try { - // convert the incoming value to the target value type - // "1" -> 1 - var convertedValue = TypeHelper.ConvertType(filterQuery.PropertyValue, relatedAttr.PropertyType); - // {model} - var parameter = Expression.Parameter(concreteType, "model"); + if (filterQuery.FilterOperation == FilterOperations.@in) + { + string[] propertyValues = filterQuery.PropertyValue.Split(','); + var lambdaIn = ArrayContainsPredicate(propertyValues, relatedAttr.Name, relation.Name); + + return source.Where(lambdaIn); + } + else + { + // convert the incoming value to the target value type + // "1" -> 1 + var convertedValue = TypeHelper.ConvertType(filterQuery.PropertyValue, relatedAttr.PropertyType); + // {model} + var parameter = Expression.Parameter(concreteType, "model"); - // {model.Relationship} - var leftRelationship = Expression.PropertyOrField(parameter, relation.Name); + // {model.Relationship} + var leftRelationship = Expression.PropertyOrField(parameter, relation.Name); - // {model.Relationship.Attr} - var left = Expression.PropertyOrField(leftRelationship, relatedAttr.Name); + // {model.Relationship.Attr} + var left = Expression.PropertyOrField(leftRelationship, relatedAttr.Name); - // {1} - var right = Expression.Constant(convertedValue, relatedAttr.PropertyType); + // {1} + var right = Expression.Constant(convertedValue, relatedAttr.PropertyType); - var body = GetFilterExpressionLambda(left, right, filterQuery.FilterOperation); + var body = GetFilterExpressionLambda(left, right, filterQuery.FilterOperation); - var lambda = Expression.Lambda>(body, parameter); + var lambda = Expression.Lambda>(body, parameter); - return source.Where(lambda); + return source.Where(lambda); + } } catch (FormatException) { @@ -206,6 +226,35 @@ private static Expression GetFilterExpressionLambda(Expression left, Expression return body; } + private static Expression> ArrayContainsPredicate(string[] propertyValues, string fieldname, string relationName = null) + { + ParameterExpression entity = Expression.Parameter(typeof(TSource), "entity"); + MemberExpression member; + if (!string.IsNullOrEmpty(relationName)) + { + var relation = Expression.PropertyOrField(entity, relationName); + member = Expression.Property(relation, fieldname); + } + else + member = Expression.Property(entity, fieldname); + + var containsMethods = typeof(Enumerable).GetMethods(BindingFlags.Static | BindingFlags.Public).Where(m => m.Name == "Contains"); + MethodInfo method = null; + foreach (var m in containsMethods) + { + if (m.GetParameters().Count() == 2) + { + method = m; + break; + } + } + method = method.MakeGenericMethod(member.Type); + var obj = TypeHelper.ConvertListType(propertyValues, member.Type); + + var exprContains = Expression.Call(method, new Expression[] { Expression.Constant(obj), member }); + return Expression.Lambda>(exprContains, entity); + } + public static IQueryable Select(this IQueryable source, List columns) { if (columns == null || columns.Count == 0) diff --git a/src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs b/src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs index e3c207ce47..88a2da2ee8 100644 --- a/src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs +++ b/src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs @@ -9,6 +9,7 @@ public enum FilterOperations le = 3, ge = 4, like = 5, - ne = 6 + ne = 6, + @in = 7, // prefix with @ to use keyword } } diff --git a/src/JsonApiDotNetCore/Internal/TypeHelper.cs b/src/JsonApiDotNetCore/Internal/TypeHelper.cs index cc64b398dd..15bd322e54 100644 --- a/src/JsonApiDotNetCore/Internal/TypeHelper.cs +++ b/src/JsonApiDotNetCore/Internal/TypeHelper.cs @@ -1,4 +1,6 @@ using System; +using System.Collections; +using System.Collections.Generic; using System.Reflection; namespace JsonApiDotNetCore.Internal @@ -54,5 +56,27 @@ public static T ConvertType(object value) { return (T)ConvertType(value, typeof(T)); } + + /// + /// Convert collection of query string params to Collection of concrete Type + /// + /// Collection like ["10","20","30"] + /// Non array type. For e.g. int + /// Collection of concrete type + public static object ConvertListType(IEnumerable values, Type type) + { + var convertedArray = new List(); + foreach (var value in values) + { + convertedArray.Add(ConvertType(value, type)); + } + var listType = typeof(List<>).MakeGenericType(type); + IList list = (IList)Activator.CreateInstance(listType); + foreach (var item in convertedArray) + { + list.Add(item); + } + return list; + } } } diff --git a/src/JsonApiDotNetCore/Services/QueryParser.cs b/src/JsonApiDotNetCore/Services/QueryParser.cs index b1a1c26f31..239ee8a3b8 100644 --- a/src/JsonApiDotNetCore/Services/QueryParser.cs +++ b/src/JsonApiDotNetCore/Services/QueryParser.cs @@ -82,14 +82,23 @@ protected virtual List ParseFilterQuery(string key, string value) // expected input = filter[id]=1 // expected input = filter[id]=eq:1 var queries = new List(); - var propertyName = key.Split(QueryConstants.OPEN_BRACKET, QueryConstants.CLOSE_BRACKET)[1]; - var values = value.Split(QueryConstants.COMMA); - foreach (var val in values) + // InArray case + var op = GetFilterOperation(value); + if (op == FilterOperations.@in.ToString()) + { + (var operation, var filterValue) = ParseFilterOperation(value); + queries.Add(new FilterQuery(propertyName, filterValue, op)); + } + else { - (var operation, var filterValue) = ParseFilterOperation(val); - queries.Add(new FilterQuery(propertyName, filterValue, operation)); + var values = value.Split(QueryConstants.COMMA); + foreach (var val in values) + { + (var operation, var filterValue) = ParseFilterOperation(val); + queries.Add(new FilterQuery(propertyName, filterValue, operation)); + } } return queries; @@ -100,19 +109,15 @@ protected virtual (string operation, string value) ParseFilterOperation(string v if (value.Length < 3) return (string.Empty, value); - var operation = value.Split(QueryConstants.COLON); + var operation = GetFilterOperation(value); + var values = value.Split(QueryConstants.COLON); - if (operation.Length == 1) - return (string.Empty, value); - - // remove prefix from value - if (Enum.TryParse(operation[0], out FilterOperations op) == false) + if (string.IsNullOrEmpty(operation)) return (string.Empty, value); - var prefix = operation[0]; - value = string.Join(QueryConstants.COLON_STR, operation.Skip(1)); + value = string.Join(QueryConstants.COLON_STR, values.Skip(1)); - return (prefix, value); + return (operation, value); } protected virtual PageQuery ParsePageQuery(PageQuery pageQuery, string key, string value) @@ -225,6 +230,20 @@ protected virtual AttrAttribute GetAttribute(string propertyName) } } + private string GetFilterOperation(string value) + { + var operation = value.Split(QueryConstants.COLON); + + if (operation.Length == 1) + return string.Empty; + + // remove prefix from value + if (Enum.TryParse(operation[0], out FilterOperations op) == false) + return string.Empty; + + return operation[0]; + } + private FilterQuery BuildFilterQuery(ReadOnlySpan query, string propertyName) { var (operation, filterValue) = ParseFilterOperation(query.ToString()); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs index b84b57e31b..b8927f71a8 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; @@ -8,6 +9,7 @@ using JsonApiDotNetCore.Serialization; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; using Xunit; using Person = JsonApiDotNetCoreExample.Models.Person; @@ -131,5 +133,74 @@ public async Task Can_Filter_On_Not_Equal_Values() Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.False(deserializedTodoItems.Any(i => i.Ordinal == todoItem.Ordinal)); } + + [Fact] + public async Task Can_Filter_On_In_Array_Values() + { + // arrange + var context = _fixture.GetService(); + var todoItems = _todoItemFaker.Generate(3); + var guids = new List(); + foreach (var item in todoItems) + { + context.TodoItems.Add(item); + guids.Add(item.GuidProperty); + } + context.SaveChanges(); + + var totalCount = context.TodoItems.Count(); + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/todo-items?filter[guid-property]=in:{string.Join(",", guids)}"; + var request = new HttpRequestMessage(httpMethod, route); + + // act + var response = await _fixture.Client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + var deserializedTodoItems = _fixture + .GetService() + .DeserializeList(body); + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(guids.Count(), deserializedTodoItems.Count()); + foreach (var item in deserializedTodoItems) + Assert.True(guids.Contains(item.GuidProperty)); + } + + [Fact] + public async Task Can_Filter_On_Related_In_Array_Values() + { + // arrange + var context = _fixture.GetService(); + var todoItems = _todoItemFaker.Generate(3); + var ownerFirstNames = new List(); + foreach (var item in todoItems) + { + var person = _personFaker.Generate(); + ownerFirstNames.Add(person.FirstName); + item.Owner = person; + context.TodoItems.Add(item); + } + context.SaveChanges(); + + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/todo-items?include=owner&filter[owner.first-name]=in:{string.Join(",", ownerFirstNames)}"; + var request = new HttpRequestMessage(httpMethod, route); + + // act + var response = await _fixture.Client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + var documents = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + var included = documents.Included; + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(ownerFirstNames.Count(), documents.Data.Count()); + Assert.NotNull(included); + Assert.NotEmpty(included); + foreach (var item in included) + Assert.True(ownerFirstNames.Contains(item.Attributes["first-name"])); + + } } } From 09b6e5bc54f07f807b1c54980eb543a5e7ea45c7 Mon Sep 17 00:00:00 2001 From: Milos Date: Wed, 6 Jun 2018 10:16:03 +0200 Subject: [PATCH 2/2] Code improvements of "in" filtering --- .../Extensions/IQueryableExtensions.cs | 29 ++++++++++++------- src/JsonApiDotNetCore/Internal/TypeHelper.cs | 12 +++----- src/JsonApiDotNetCore/Services/QueryParser.cs | 13 +++++---- .../Acceptance/Spec/AttributeFilterTests.cs | 12 ++++++-- 4 files changed, 39 insertions(+), 27 deletions(-) diff --git a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs index c392da2b93..994fc08070 100644 --- a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs @@ -12,6 +12,23 @@ namespace JsonApiDotNetCore.Extensions // ReSharper disable once InconsistentNaming public static class IQueryableExtensions { + private static MethodInfo _containsMethod; + private static MethodInfo ContainsMethod + { + get + { + if (_containsMethod == null) + { + _containsMethod = typeof(Enumerable) + .GetMethods(BindingFlags.Static | BindingFlags.Public) + .Where(m => m.Name == nameof(Enumerable.Contains) && m.GetParameters().Count() == 2) + .First(); + } + return _containsMethod; + } + } + + public static IQueryable Sort(this IQueryable source, List sortQueries) { if (sortQueries == null || sortQueries.Count == 0) @@ -238,17 +255,7 @@ private static Expression> ArrayContainsPredicate(s else member = Expression.Property(entity, fieldname); - var containsMethods = typeof(Enumerable).GetMethods(BindingFlags.Static | BindingFlags.Public).Where(m => m.Name == "Contains"); - MethodInfo method = null; - foreach (var m in containsMethods) - { - if (m.GetParameters().Count() == 2) - { - method = m; - break; - } - } - method = method.MakeGenericMethod(member.Type); + var method = ContainsMethod.MakeGenericMethod(member.Type); var obj = TypeHelper.ConvertListType(propertyValues, member.Type); var exprContains = Expression.Call(method, new Expression[] { Expression.Constant(obj), member }); diff --git a/src/JsonApiDotNetCore/Internal/TypeHelper.cs b/src/JsonApiDotNetCore/Internal/TypeHelper.cs index 15bd322e54..5135473cdb 100644 --- a/src/JsonApiDotNetCore/Internal/TypeHelper.cs +++ b/src/JsonApiDotNetCore/Internal/TypeHelper.cs @@ -63,19 +63,15 @@ public static T ConvertType(object value) /// Collection like ["10","20","30"] /// Non array type. For e.g. int /// Collection of concrete type - public static object ConvertListType(IEnumerable values, Type type) + public static IList ConvertListType(IEnumerable values, Type type) { - var convertedArray = new List(); - foreach (var value in values) - { - convertedArray.Add(ConvertType(value, type)); - } var listType = typeof(List<>).MakeGenericType(type); IList list = (IList)Activator.CreateInstance(listType); - foreach (var item in convertedArray) + foreach (var value in values) { - list.Add(item); + list.Add(ConvertType(value, type)); } + return list; } } diff --git a/src/JsonApiDotNetCore/Services/QueryParser.cs b/src/JsonApiDotNetCore/Services/QueryParser.cs index 239ee8a3b8..7e17352815 100644 --- a/src/JsonApiDotNetCore/Services/QueryParser.cs +++ b/src/JsonApiDotNetCore/Services/QueryParser.cs @@ -85,8 +85,8 @@ protected virtual List ParseFilterQuery(string key, string value) var propertyName = key.Split(QueryConstants.OPEN_BRACKET, QueryConstants.CLOSE_BRACKET)[1]; // InArray case - var op = GetFilterOperation(value); - if (op == FilterOperations.@in.ToString()) + string op = GetFilterOperation(value); + if (string.Equals(op, FilterOperations.@in.ToString(), StringComparison.OrdinalIgnoreCase)) { (var operation, var filterValue) = ParseFilterOperation(value); queries.Add(new FilterQuery(propertyName, filterValue, op)); @@ -232,16 +232,17 @@ protected virtual AttrAttribute GetAttribute(string propertyName) private string GetFilterOperation(string value) { - var operation = value.Split(QueryConstants.COLON); + var values = value.Split(QueryConstants.COLON); - if (operation.Length == 1) + if (values.Length == 1) return string.Empty; + var operation = values[0]; // remove prefix from value - if (Enum.TryParse(operation[0], out FilterOperations op) == false) + if (Enum.TryParse(operation, out FilterOperations op) == false) return string.Empty; - return operation[0]; + return operation; } private FilterQuery BuildFilterQuery(ReadOnlySpan query, string propertyName) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs index b8927f71a8..428e3cadf3 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs @@ -139,12 +139,17 @@ public async Task Can_Filter_On_In_Array_Values() { // arrange var context = _fixture.GetService(); - var todoItems = _todoItemFaker.Generate(3); + var todoItems = _todoItemFaker.Generate(5); var guids = new List(); + var notInGuids = new List(); foreach (var item in todoItems) { context.TodoItems.Add(item); - guids.Add(item.GuidProperty); + // Exclude 2 items + if (guids.Count < (todoItems.Count() - 2)) + guids.Add(item.GuidProperty); + else + notInGuids.Add(item.GuidProperty); } context.SaveChanges(); @@ -164,7 +169,10 @@ public async Task Can_Filter_On_In_Array_Values() Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(guids.Count(), deserializedTodoItems.Count()); foreach (var item in deserializedTodoItems) + { Assert.True(guids.Contains(item.GuidProperty)); + Assert.False(notInGuids.Contains(item.GuidProperty)); + } } [Fact]