Skip to content

Commit

Permalink
Merge pull request #288 from milosloub/master
Browse files Browse the repository at this point in the history
Add IN filter operation for array searching
  • Loading branch information
jaredcnance authored Jun 6, 2018
2 parents e8cbc4b + 09b6e5b commit a1832f7
Show file tree
Hide file tree
Showing 5 changed files with 220 additions and 44 deletions.
114 changes: 85 additions & 29 deletions src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<TSource> Sort<TSource>(this IQueryable<TSource> source, List<SortQuery> sortQueries)
{
if (sortQueries == null || sortQueries.Count == 0)
Expand Down Expand Up @@ -101,21 +119,30 @@ public static IQueryable<TSource> Filter<TSource>(this IQueryable<TSource> 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<Func<TSource, bool>>(body, parameter);

return source.Where(lambda);
if (filterQuery.FilterOperation == FilterOperations.@in )
{
string[] propertyValues = filterQuery.PropertyValue.Split(',');
var lambdaIn = ArrayContainsPredicate<TSource>(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<Func<TSource, bool>>(body, parameter);

return source.Where(lambda);
}
}
catch (FormatException)
{
Expand All @@ -140,26 +167,36 @@ public static IQueryable<TSource> Filter<TSource>(this IQueryable<TSource> 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<TSource>(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<Func<TSource, bool>>(body, parameter);
var body = GetFilterExpressionLambda(left, right, filterQuery.FilterOperation);

return source.Where(lambda);
var lambda = Expression.Lambda<Func<TSource, bool>>(body, parameter);

return source.Where(lambda);
}
}
catch (FormatException)
{
Expand Down Expand Up @@ -206,6 +243,25 @@ private static Expression GetFilterExpressionLambda(Expression left, Expression
return body;
}

private static Expression<Func<TSource, bool>> ArrayContainsPredicate<TSource>(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<Func<TSource, bool>>(exprContains, entity);
}

public static IQueryable<TSource> Select<TSource>(this IQueryable<TSource> source, List<string> columns)
{
if (columns == null || columns.Count == 0)
Expand Down
3 changes: 2 additions & 1 deletion src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public enum FilterOperations
le = 3,
ge = 4,
like = 5,
ne = 6
ne = 6,
@in = 7, // prefix with @ to use keyword
}
}
20 changes: 20 additions & 0 deletions src/JsonApiDotNetCore/Internal/TypeHelper.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Reflection;

namespace JsonApiDotNetCore.Internal
Expand Down Expand Up @@ -54,5 +56,23 @@ public static T ConvertType<T>(object value)
{
return (T)ConvertType(value, typeof(T));
}

/// <summary>
/// Convert collection of query string params to Collection of concrete Type
/// </summary>
/// <param name="values">Collection like ["10","20","30"]</param>
/// <param name="type">Non array type. For e.g. int</param>
/// <returns>Collection of concrete type</returns>
public static IList ConvertListType(IEnumerable<string> 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;
}
}
}
48 changes: 34 additions & 14 deletions src/JsonApiDotNetCore/Services/QueryParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,23 @@ protected virtual List<FilterQuery> ParseFilterQuery(string key, string value)
// expected input = filter[id]=1
// expected input = filter[id]=eq:1
var queries = new List<FilterQuery>();

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;
Expand All @@ -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)
Expand Down Expand Up @@ -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<char> query, string propertyName)
{
var (operation, filterValue) = ParseFilterOperation(query.ToString());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
Expand All @@ -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;
Expand Down Expand Up @@ -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<AppDbContext>();
var todoItems = _todoItemFaker.Generate(5);
var guids = new List<Guid>();
var notInGuids = new List<Guid>();
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<IJsonApiDeSerializer>()
.DeserializeList<TodoItem>(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<AppDbContext>();
var todoItems = _todoItemFaker.Generate(3);
var ownerFirstNames = new List<string>();
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<Documents>(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"]));

}
}
}

0 comments on commit a1832f7

Please sign in to comment.