Skip to content

Commit 88f5cbe

Browse files
MilosMilos
Milos
authored and
Milos
committed
Add IN filter for array searching
1 parent 8817ff6 commit 88f5cbe

File tree

5 files changed

+208
-44
lines changed

5 files changed

+208
-44
lines changed

src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs

Lines changed: 78 additions & 29 deletions
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;
@@ -101,21 +102,30 @@ public static IQueryable<TSource> Filter<TSource>(this IQueryable<TSource> sourc
101102

102103
try
103104
{
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);
105+
if (filterQuery.FilterOperation == FilterOperations.@in )
106+
{
107+
string[] propertyValues = filterQuery.PropertyValue.Split(',');
108+
var lambdaIn = ArrayContainsPredicate<TSource>(propertyValues, property.Name);
109+
110+
return source.Where(lambdaIn);
111+
}
112+
else
113+
{ // convert the incoming value to the target value type
114+
// "1" -> 1
115+
var convertedValue = TypeHelper.ConvertType(filterQuery.PropertyValue, property.PropertyType);
116+
// {model}
117+
var parameter = Expression.Parameter(concreteType, "model");
118+
// {model.Id}
119+
var left = Expression.PropertyOrField(parameter, property.Name);
120+
// {1}
121+
var right = Expression.Constant(convertedValue, property.PropertyType);
122+
123+
var body = GetFilterExpressionLambda(left, right, filterQuery.FilterOperation);
124+
125+
var lambda = Expression.Lambda<Func<TSource, bool>>(body, parameter);
126+
127+
return source.Where(lambda);
128+
}
119129
}
120130
catch (FormatException)
121131
{
@@ -140,26 +150,36 @@ public static IQueryable<TSource> Filter<TSource>(this IQueryable<TSource> sourc
140150

141151
try
142152
{
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");
153+
if (filterQuery.FilterOperation == FilterOperations.@in)
154+
{
155+
string[] propertyValues = filterQuery.PropertyValue.Split(',');
156+
var lambdaIn = ArrayContainsPredicate<TSource>(propertyValues, relatedAttr.Name, relation.Name);
157+
158+
return source.Where(lambdaIn);
159+
}
160+
else
161+
{
162+
// convert the incoming value to the target value type
163+
// "1" -> 1
164+
var convertedValue = TypeHelper.ConvertType(filterQuery.PropertyValue, relatedAttr.PropertyType);
165+
// {model}
166+
var parameter = Expression.Parameter(concreteType, "model");
148167

149-
// {model.Relationship}
150-
var leftRelationship = Expression.PropertyOrField(parameter, relation.Name);
168+
// {model.Relationship}
169+
var leftRelationship = Expression.PropertyOrField(parameter, relation.Name);
151170

152-
// {model.Relationship.Attr}
153-
var left = Expression.PropertyOrField(leftRelationship, relatedAttr.Name);
171+
// {model.Relationship.Attr}
172+
var left = Expression.PropertyOrField(leftRelationship, relatedAttr.Name);
154173

155-
// {1}
156-
var right = Expression.Constant(convertedValue, relatedAttr.PropertyType);
174+
// {1}
175+
var right = Expression.Constant(convertedValue, relatedAttr.PropertyType);
157176

158-
var body = GetFilterExpressionLambda(left, right, filterQuery.FilterOperation);
177+
var body = GetFilterExpressionLambda(left, right, filterQuery.FilterOperation);
159178

160-
var lambda = Expression.Lambda<Func<TSource, bool>>(body, parameter);
179+
var lambda = Expression.Lambda<Func<TSource, bool>>(body, parameter);
161180

162-
return source.Where(lambda);
181+
return source.Where(lambda);
182+
}
163183
}
164184
catch (FormatException)
165185
{
@@ -206,6 +226,35 @@ private static Expression GetFilterExpressionLambda(Expression left, Expression
206226
return body;
207227
}
208228

229+
private static Expression<Func<TSource, bool>> ArrayContainsPredicate<TSource>(string[] propertyValues, string fieldname, string relationName = null)
230+
{
231+
ParameterExpression entity = Expression.Parameter(typeof(TSource), "entity");
232+
MemberExpression member;
233+
if (!string.IsNullOrEmpty(relationName))
234+
{
235+
var relation = Expression.PropertyOrField(entity, relationName);
236+
member = Expression.Property(relation, fieldname);
237+
}
238+
else
239+
member = Expression.Property(entity, fieldname);
240+
241+
var containsMethods = typeof(Enumerable).GetMethods(BindingFlags.Static | BindingFlags.Public).Where(m => m.Name == "Contains");
242+
MethodInfo method = null;
243+
foreach (var m in containsMethods)
244+
{
245+
if (m.GetParameters().Count() == 2)
246+
{
247+
method = m;
248+
break;
249+
}
250+
}
251+
method = method.MakeGenericMethod(member.Type);
252+
var obj = TypeHelper.ConvertListType(propertyValues, member.Type);
253+
254+
var exprContains = Expression.Call(method, new Expression[] { Expression.Constant(obj), member });
255+
return Expression.Lambda<Func<TSource, bool>>(exprContains, entity);
256+
}
257+
209258
public static IQueryable<TSource> Select<TSource>(this IQueryable<TSource> source, List<string> columns)
210259
{
211260
if (columns == null || columns.Count == 0)

src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs

Lines changed: 2 additions & 1 deletion
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

Lines changed: 24 additions & 0 deletions
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,27 @@ 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 object ConvertListType(IEnumerable<string> values, Type type)
67+
{
68+
var convertedArray = new List<object>();
69+
foreach (var value in values)
70+
{
71+
convertedArray.Add(ConvertType(value, type));
72+
}
73+
var listType = typeof(List<>).MakeGenericType(type);
74+
IList list = (IList)Activator.CreateInstance(listType);
75+
foreach (var item in convertedArray)
76+
{
77+
list.Add(item);
78+
}
79+
return list;
80+
}
5781
}
5882
}

src/JsonApiDotNetCore/Services/QueryParser.cs

Lines changed: 33 additions & 14 deletions
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+
var op = GetFilterOperation(value);
89+
if (op == FilterOperations.@in.ToString())
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);
112+
var operation = GetFilterOperation(value);
113+
var values = value.Split(QueryConstants.COLON);
104114

105-
if (operation.Length == 1)
106-
return (string.Empty, value);
107-
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,20 @@ protected virtual AttrAttribute GetAttribute(string propertyName)
225230
}
226231
}
227232

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

test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs

Lines changed: 71 additions & 0 deletions
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,74 @@ 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(3);
143+
var guids = new List<Guid>();
144+
foreach (var item in todoItems)
145+
{
146+
context.TodoItems.Add(item);
147+
guids.Add(item.GuidProperty);
148+
}
149+
context.SaveChanges();
150+
151+
var totalCount = context.TodoItems.Count();
152+
var httpMethod = new HttpMethod("GET");
153+
var route = $"/api/v1/todo-items?filter[guid-property]=in:{string.Join(",", guids)}";
154+
var request = new HttpRequestMessage(httpMethod, route);
155+
156+
// act
157+
var response = await _fixture.Client.SendAsync(request);
158+
var body = await response.Content.ReadAsStringAsync();
159+
var deserializedTodoItems = _fixture
160+
.GetService<IJsonApiDeSerializer>()
161+
.DeserializeList<TodoItem>(body);
162+
163+
// assert
164+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
165+
Assert.Equal(guids.Count(), deserializedTodoItems.Count());
166+
foreach (var item in deserializedTodoItems)
167+
Assert.True(guids.Contains(item.GuidProperty));
168+
}
169+
170+
[Fact]
171+
public async Task Can_Filter_On_Related_In_Array_Values()
172+
{
173+
// arrange
174+
var context = _fixture.GetService<AppDbContext>();
175+
var todoItems = _todoItemFaker.Generate(3);
176+
var ownerFirstNames = new List<string>();
177+
foreach (var item in todoItems)
178+
{
179+
var person = _personFaker.Generate();
180+
ownerFirstNames.Add(person.FirstName);
181+
item.Owner = person;
182+
context.TodoItems.Add(item);
183+
}
184+
context.SaveChanges();
185+
186+
var httpMethod = new HttpMethod("GET");
187+
var route = $"/api/v1/todo-items?include=owner&filter[owner.first-name]=in:{string.Join(",", ownerFirstNames)}";
188+
var request = new HttpRequestMessage(httpMethod, route);
189+
190+
// act
191+
var response = await _fixture.Client.SendAsync(request);
192+
var body = await response.Content.ReadAsStringAsync();
193+
var documents = JsonConvert.DeserializeObject<Documents>(await response.Content.ReadAsStringAsync());
194+
var included = documents.Included;
195+
196+
// assert
197+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
198+
Assert.Equal(ownerFirstNames.Count(), documents.Data.Count());
199+
Assert.NotNull(included);
200+
Assert.NotEmpty(included);
201+
foreach (var item in included)
202+
Assert.True(ownerFirstNames.Contains(item.Attributes["first-name"]));
203+
204+
}
134205
}
135206
}

0 commit comments

Comments
 (0)