Skip to content

Commit a1832f7

Browse files
authored
Merge pull request #288 from milosloub/master
Add IN filter operation for array searching
2 parents e8cbc4b + 09b6e5b commit a1832f7

File tree

5 files changed

+220
-44
lines changed

5 files changed

+220
-44
lines changed

src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs

+85-29
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Collections.Generic;
33
using System.Linq;
44
using System.Linq.Expressions;
5+
using System.Reflection;
56
using JsonApiDotNetCore.Internal;
67
using JsonApiDotNetCore.Internal.Query;
78
using JsonApiDotNetCore.Services;
@@ -11,6 +12,23 @@ namespace JsonApiDotNetCore.Extensions
1112
// ReSharper disable once InconsistentNaming
1213
public static class IQueryableExtensions
1314
{
15+
private static MethodInfo _containsMethod;
16+
private static MethodInfo ContainsMethod
17+
{
18+
get
19+
{
20+
if (_containsMethod == null)
21+
{
22+
_containsMethod = typeof(Enumerable)
23+
.GetMethods(BindingFlags.Static | BindingFlags.Public)
24+
.Where(m => m.Name == nameof(Enumerable.Contains) && m.GetParameters().Count() == 2)
25+
.First();
26+
}
27+
return _containsMethod;
28+
}
29+
}
30+
31+
1432
public static IQueryable<TSource> Sort<TSource>(this IQueryable<TSource> source, List<SortQuery> sortQueries)
1533
{
1634
if (sortQueries == null || sortQueries.Count == 0)
@@ -101,21 +119,30 @@ public static IQueryable<TSource> Filter<TSource>(this IQueryable<TSource> sourc
101119

102120
try
103121
{
104-
// convert the incoming value to the target value type
105-
// "1" -> 1
106-
var convertedValue = TypeHelper.ConvertType(filterQuery.PropertyValue, property.PropertyType);
107-
// {model}
108-
var parameter = Expression.Parameter(concreteType, "model");
109-
// {model.Id}
110-
var left = Expression.PropertyOrField(parameter, property.Name);
111-
// {1}
112-
var right = Expression.Constant(convertedValue, property.PropertyType);
113-
114-
var body = GetFilterExpressionLambda(left, right, filterQuery.FilterOperation);
115-
116-
var lambda = Expression.Lambda<Func<TSource, bool>>(body, parameter);
117-
118-
return source.Where(lambda);
122+
if (filterQuery.FilterOperation == FilterOperations.@in )
123+
{
124+
string[] propertyValues = filterQuery.PropertyValue.Split(',');
125+
var lambdaIn = ArrayContainsPredicate<TSource>(propertyValues, property.Name);
126+
127+
return source.Where(lambdaIn);
128+
}
129+
else
130+
{ // convert the incoming value to the target value type
131+
// "1" -> 1
132+
var convertedValue = TypeHelper.ConvertType(filterQuery.PropertyValue, property.PropertyType);
133+
// {model}
134+
var parameter = Expression.Parameter(concreteType, "model");
135+
// {model.Id}
136+
var left = Expression.PropertyOrField(parameter, property.Name);
137+
// {1}
138+
var right = Expression.Constant(convertedValue, property.PropertyType);
139+
140+
var body = GetFilterExpressionLambda(left, right, filterQuery.FilterOperation);
141+
142+
var lambda = Expression.Lambda<Func<TSource, bool>>(body, parameter);
143+
144+
return source.Where(lambda);
145+
}
119146
}
120147
catch (FormatException)
121148
{
@@ -140,26 +167,36 @@ public static IQueryable<TSource> Filter<TSource>(this IQueryable<TSource> sourc
140167

141168
try
142169
{
143-
// convert the incoming value to the target value type
144-
// "1" -> 1
145-
var convertedValue = TypeHelper.ConvertType(filterQuery.PropertyValue, relatedAttr.PropertyType);
146-
// {model}
147-
var parameter = Expression.Parameter(concreteType, "model");
170+
if (filterQuery.FilterOperation == FilterOperations.@in)
171+
{
172+
string[] propertyValues = filterQuery.PropertyValue.Split(',');
173+
var lambdaIn = ArrayContainsPredicate<TSource>(propertyValues, relatedAttr.Name, relation.Name);
148174

149-
// {model.Relationship}
150-
var leftRelationship = Expression.PropertyOrField(parameter, relation.Name);
175+
return source.Where(lambdaIn);
176+
}
177+
else
178+
{
179+
// convert the incoming value to the target value type
180+
// "1" -> 1
181+
var convertedValue = TypeHelper.ConvertType(filterQuery.PropertyValue, relatedAttr.PropertyType);
182+
// {model}
183+
var parameter = Expression.Parameter(concreteType, "model");
151184

152-
// {model.Relationship.Attr}
153-
var left = Expression.PropertyOrField(leftRelationship, relatedAttr.Name);
185+
// {model.Relationship}
186+
var leftRelationship = Expression.PropertyOrField(parameter, relation.Name);
154187

155-
// {1}
156-
var right = Expression.Constant(convertedValue, relatedAttr.PropertyType);
188+
// {model.Relationship.Attr}
189+
var left = Expression.PropertyOrField(leftRelationship, relatedAttr.Name);
157190

158-
var body = GetFilterExpressionLambda(left, right, filterQuery.FilterOperation);
191+
// {1}
192+
var right = Expression.Constant(convertedValue, relatedAttr.PropertyType);
159193

160-
var lambda = Expression.Lambda<Func<TSource, bool>>(body, parameter);
194+
var body = GetFilterExpressionLambda(left, right, filterQuery.FilterOperation);
161195

162-
return source.Where(lambda);
196+
var lambda = Expression.Lambda<Func<TSource, bool>>(body, parameter);
197+
198+
return source.Where(lambda);
199+
}
163200
}
164201
catch (FormatException)
165202
{
@@ -206,6 +243,25 @@ private static Expression GetFilterExpressionLambda(Expression left, Expression
206243
return body;
207244
}
208245

246+
private static Expression<Func<TSource, bool>> ArrayContainsPredicate<TSource>(string[] propertyValues, string fieldname, string relationName = null)
247+
{
248+
ParameterExpression entity = Expression.Parameter(typeof(TSource), "entity");
249+
MemberExpression member;
250+
if (!string.IsNullOrEmpty(relationName))
251+
{
252+
var relation = Expression.PropertyOrField(entity, relationName);
253+
member = Expression.Property(relation, fieldname);
254+
}
255+
else
256+
member = Expression.Property(entity, fieldname);
257+
258+
var method = ContainsMethod.MakeGenericMethod(member.Type);
259+
var obj = TypeHelper.ConvertListType(propertyValues, member.Type);
260+
261+
var exprContains = Expression.Call(method, new Expression[] { Expression.Constant(obj), member });
262+
return Expression.Lambda<Func<TSource, bool>>(exprContains, entity);
263+
}
264+
209265
public static IQueryable<TSource> Select<TSource>(this IQueryable<TSource> source, List<string> columns)
210266
{
211267
if (columns == null || columns.Count == 0)

src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs

+2-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ public enum FilterOperations
99
le = 3,
1010
ge = 4,
1111
like = 5,
12-
ne = 6
12+
ne = 6,
13+
@in = 7, // prefix with @ to use keyword
1314
}
1415
}

src/JsonApiDotNetCore/Internal/TypeHelper.cs

+20
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
using System;
2+
using System.Collections;
3+
using System.Collections.Generic;
24
using System.Reflection;
35

46
namespace JsonApiDotNetCore.Internal
@@ -54,5 +56,23 @@ public static T ConvertType<T>(object value)
5456
{
5557
return (T)ConvertType(value, typeof(T));
5658
}
59+
60+
/// <summary>
61+
/// Convert collection of query string params to Collection of concrete Type
62+
/// </summary>
63+
/// <param name="values">Collection like ["10","20","30"]</param>
64+
/// <param name="type">Non array type. For e.g. int</param>
65+
/// <returns>Collection of concrete type</returns>
66+
public static IList ConvertListType(IEnumerable<string> values, Type type)
67+
{
68+
var listType = typeof(List<>).MakeGenericType(type);
69+
IList list = (IList)Activator.CreateInstance(listType);
70+
foreach (var value in values)
71+
{
72+
list.Add(ConvertType(value, type));
73+
}
74+
75+
return list;
76+
}
5777
}
5878
}

src/JsonApiDotNetCore/Services/QueryParser.cs

+34-14
Original file line numberDiff line numberDiff line change
@@ -82,14 +82,23 @@ protected virtual List<FilterQuery> ParseFilterQuery(string key, string value)
8282
// expected input = filter[id]=1
8383
// expected input = filter[id]=eq:1
8484
var queries = new List<FilterQuery>();
85-
8685
var propertyName = key.Split(QueryConstants.OPEN_BRACKET, QueryConstants.CLOSE_BRACKET)[1];
8786

88-
var values = value.Split(QueryConstants.COMMA);
89-
foreach (var val in values)
87+
// InArray case
88+
string op = GetFilterOperation(value);
89+
if (string.Equals(op, FilterOperations.@in.ToString(), StringComparison.OrdinalIgnoreCase))
90+
{
91+
(var operation, var filterValue) = ParseFilterOperation(value);
92+
queries.Add(new FilterQuery(propertyName, filterValue, op));
93+
}
94+
else
9095
{
91-
(var operation, var filterValue) = ParseFilterOperation(val);
92-
queries.Add(new FilterQuery(propertyName, filterValue, operation));
96+
var values = value.Split(QueryConstants.COMMA);
97+
foreach (var val in values)
98+
{
99+
(var operation, var filterValue) = ParseFilterOperation(val);
100+
queries.Add(new FilterQuery(propertyName, filterValue, operation));
101+
}
93102
}
94103

95104
return queries;
@@ -100,19 +109,15 @@ protected virtual (string operation, string value) ParseFilterOperation(string v
100109
if (value.Length < 3)
101110
return (string.Empty, value);
102111

103-
var operation = value.Split(QueryConstants.COLON);
104-
105-
if (operation.Length == 1)
106-
return (string.Empty, value);
112+
var operation = GetFilterOperation(value);
113+
var values = value.Split(QueryConstants.COLON);
107114

108-
// remove prefix from value
109-
if (Enum.TryParse(operation[0], out FilterOperations op) == false)
115+
if (string.IsNullOrEmpty(operation))
110116
return (string.Empty, value);
111117

112-
var prefix = operation[0];
113-
value = string.Join(QueryConstants.COLON_STR, operation.Skip(1));
118+
value = string.Join(QueryConstants.COLON_STR, values.Skip(1));
114119

115-
return (prefix, value);
120+
return (operation, value);
116121
}
117122

118123
protected virtual PageQuery ParsePageQuery(PageQuery pageQuery, string key, string value)
@@ -225,6 +230,21 @@ protected virtual AttrAttribute GetAttribute(string propertyName)
225230
}
226231
}
227232

233+
private string GetFilterOperation(string value)
234+
{
235+
var values = value.Split(QueryConstants.COLON);
236+
237+
if (values.Length == 1)
238+
return string.Empty;
239+
240+
var operation = values[0];
241+
// remove prefix from value
242+
if (Enum.TryParse(operation, out FilterOperations op) == false)
243+
return string.Empty;
244+
245+
return operation;
246+
}
247+
228248
private FilterQuery BuildFilterQuery(ReadOnlySpan<char> query, string propertyName)
229249
{
230250
var (operation, filterValue) = ParseFilterOperation(query.ToString());

test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs

+79
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Collections.Generic;
23
using System.Linq;
34
using System.Net;
45
using System.Net.Http;
@@ -8,6 +9,7 @@
89
using JsonApiDotNetCore.Serialization;
910
using JsonApiDotNetCoreExample.Data;
1011
using JsonApiDotNetCoreExample.Models;
12+
using Microsoft.EntityFrameworkCore;
1113
using Newtonsoft.Json;
1214
using Xunit;
1315
using Person = JsonApiDotNetCoreExample.Models.Person;
@@ -131,5 +133,82 @@ public async Task Can_Filter_On_Not_Equal_Values()
131133
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
132134
Assert.False(deserializedTodoItems.Any(i => i.Ordinal == todoItem.Ordinal));
133135
}
136+
137+
[Fact]
138+
public async Task Can_Filter_On_In_Array_Values()
139+
{
140+
// arrange
141+
var context = _fixture.GetService<AppDbContext>();
142+
var todoItems = _todoItemFaker.Generate(5);
143+
var guids = new List<Guid>();
144+
var notInGuids = new List<Guid>();
145+
foreach (var item in todoItems)
146+
{
147+
context.TodoItems.Add(item);
148+
// Exclude 2 items
149+
if (guids.Count < (todoItems.Count() - 2))
150+
guids.Add(item.GuidProperty);
151+
else
152+
notInGuids.Add(item.GuidProperty);
153+
}
154+
context.SaveChanges();
155+
156+
var totalCount = context.TodoItems.Count();
157+
var httpMethod = new HttpMethod("GET");
158+
var route = $"/api/v1/todo-items?filter[guid-property]=in:{string.Join(",", guids)}";
159+
var request = new HttpRequestMessage(httpMethod, route);
160+
161+
// act
162+
var response = await _fixture.Client.SendAsync(request);
163+
var body = await response.Content.ReadAsStringAsync();
164+
var deserializedTodoItems = _fixture
165+
.GetService<IJsonApiDeSerializer>()
166+
.DeserializeList<TodoItem>(body);
167+
168+
// assert
169+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
170+
Assert.Equal(guids.Count(), deserializedTodoItems.Count());
171+
foreach (var item in deserializedTodoItems)
172+
{
173+
Assert.True(guids.Contains(item.GuidProperty));
174+
Assert.False(notInGuids.Contains(item.GuidProperty));
175+
}
176+
}
177+
178+
[Fact]
179+
public async Task Can_Filter_On_Related_In_Array_Values()
180+
{
181+
// arrange
182+
var context = _fixture.GetService<AppDbContext>();
183+
var todoItems = _todoItemFaker.Generate(3);
184+
var ownerFirstNames = new List<string>();
185+
foreach (var item in todoItems)
186+
{
187+
var person = _personFaker.Generate();
188+
ownerFirstNames.Add(person.FirstName);
189+
item.Owner = person;
190+
context.TodoItems.Add(item);
191+
}
192+
context.SaveChanges();
193+
194+
var httpMethod = new HttpMethod("GET");
195+
var route = $"/api/v1/todo-items?include=owner&filter[owner.first-name]=in:{string.Join(",", ownerFirstNames)}";
196+
var request = new HttpRequestMessage(httpMethod, route);
197+
198+
// act
199+
var response = await _fixture.Client.SendAsync(request);
200+
var body = await response.Content.ReadAsStringAsync();
201+
var documents = JsonConvert.DeserializeObject<Documents>(await response.Content.ReadAsStringAsync());
202+
var included = documents.Included;
203+
204+
// assert
205+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
206+
Assert.Equal(ownerFirstNames.Count(), documents.Data.Count());
207+
Assert.NotNull(included);
208+
Assert.NotEmpty(included);
209+
foreach (var item in included)
210+
Assert.True(ownerFirstNames.Contains(item.Attributes["first-name"]));
211+
212+
}
134213
}
135214
}

0 commit comments

Comments
 (0)