diff --git a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs index 79eda6c598..994fc08070 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; @@ -11,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) @@ -101,21 +119,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 +167,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); - // {model.Relationship} - var leftRelationship = Expression.PropertyOrField(parameter, 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.Attr} - var left = Expression.PropertyOrField(leftRelationship, relatedAttr.Name); + // {model.Relationship} + var leftRelationship = Expression.PropertyOrField(parameter, relation.Name); - // {1} - var right = Expression.Constant(convertedValue, relatedAttr.PropertyType); + // {model.Relationship.Attr} + var left = Expression.PropertyOrField(leftRelationship, relatedAttr.Name); - var body = GetFilterExpressionLambda(left, right, filterQuery.FilterOperation); + // {1} + var right = Expression.Constant(convertedValue, relatedAttr.PropertyType); - var lambda = Expression.Lambda>(body, parameter); + var body = GetFilterExpressionLambda(left, right, filterQuery.FilterOperation); - return source.Where(lambda); + var lambda = Expression.Lambda>(body, parameter); + + return source.Where(lambda); + } } catch (FormatException) { @@ -206,6 +243,25 @@ 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 method = ContainsMethod.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..5135473cdb 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,23 @@ 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 IList ConvertListType(IEnumerable values, Type type) + { + var listType = typeof(List<>).MakeGenericType(type); + IList list = (IList)Activator.CreateInstance(listType); + foreach (var value in values) + { + list.Add(ConvertType(value, type)); + } + + return list; + } } } diff --git a/src/JsonApiDotNetCore/Services/QueryParser.cs b/src/JsonApiDotNetCore/Services/QueryParser.cs index b1a1c26f31..7e17352815 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 + 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)); + } + 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); - - if (operation.Length == 1) - return (string.Empty, value); + var operation = GetFilterOperation(value); + var values = value.Split(QueryConstants.COLON); - // 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,21 @@ protected virtual AttrAttribute GetAttribute(string propertyName) } } + private string GetFilterOperation(string value) + { + var values = value.Split(QueryConstants.COLON); + + if (values.Length == 1) + return string.Empty; + + var operation = values[0]; + // remove prefix from value + if (Enum.TryParse(operation, out FilterOperations op) == false) + return string.Empty; + + return operation; + } + 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..428e3cadf3 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,82 @@ 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(5); + var guids = new List(); + var notInGuids = new List(); + foreach (var item in todoItems) + { + context.TodoItems.Add(item); + // Exclude 2 items + if (guids.Count < (todoItems.Count() - 2)) + guids.Add(item.GuidProperty); + else + notInGuids.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)); + Assert.False(notInGuids.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"])); + + } } }