Skip to content

Commit c6ed084

Browse files
committed
feat(filtering): add support for operators other than equality
1 parent ed6ebc0 commit c6ed084

File tree

10 files changed

+221
-27
lines changed

10 files changed

+221
-27
lines changed

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,25 @@ add to the service collection in `Startup.ConfigureServices` like so:
145145
```
146146
services.AddScoped<IEntityRepository<MyEntity,Guid>, MyEntityRepository>();
147147
```
148+
149+
150+
## Filtering
151+
152+
You can filter resources by attributes using the `filter` query parameter.
153+
By default, all attributes are filterable.
154+
The filtering strategy we have selected, uses the following form:
155+
156+
```
157+
?filter[attribute]=value
158+
```
159+
160+
For operations other than equality, the query can be prefixed with an operation
161+
identifier):
162+
163+
```
164+
?filter[attribute]=eq:value
165+
?filter[attribute]=lt:value
166+
?filter[attribute]=gt:value
167+
?filter[attribute]=le:value
168+
?filter[attribute]=ge:value
169+
```

src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs

Lines changed: 53 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.Linq;
44
using System.Linq.Expressions;
55
using System.Reflection;
6+
using JsonApiDotNetCore.Internal;
67
using JsonApiDotNetCore.Internal.Query;
78

89
namespace JsonApiDotNetCore.Extensions
@@ -11,23 +12,23 @@ public static class IQueryableExtensions
1112
{
1213
public static IOrderedQueryable<TSource> Sort<TSource>(this IQueryable<TSource> source, SortQuery sortQuery)
1314
{
14-
if(sortQuery.Direction == SortDirection.Descending)
15+
if (sortQuery.Direction == SortDirection.Descending)
1516
return source.OrderByDescending(sortQuery.SortedAttribute.InternalAttributeName);
16-
17+
1718
return source.OrderBy(sortQuery.SortedAttribute.InternalAttributeName);
1819
}
1920

2021
public static IOrderedQueryable<TSource> Sort<TSource>(this IOrderedQueryable<TSource> source, SortQuery sortQuery)
2122
{
22-
if(sortQuery.Direction == SortDirection.Descending)
23+
if (sortQuery.Direction == SortDirection.Descending)
2324
return source.ThenByDescending(sortQuery.SortedAttribute.InternalAttributeName);
24-
25+
2526
return source.ThenBy(sortQuery.SortedAttribute.InternalAttributeName);
2627
}
2728

2829
public static IOrderedQueryable<TSource> OrderBy<TSource>(this IQueryable<TSource> source, string propertyName)
2930
{
30-
return CallGenericOrderMethod(source, propertyName, "OrderBy");
31+
return CallGenericOrderMethod(source, propertyName, "OrderBy");
3132
}
3233

3334
public static IOrderedQueryable<TSource> OrderByDescending<TSource>(this IQueryable<TSource> source, string propertyName)
@@ -47,7 +48,7 @@ public static IOrderedQueryable<TSource> ThenByDescending<TSource>(this IOrdered
4748

4849
private static IOrderedQueryable<TSource> CallGenericOrderMethod<TSource>(IQueryable<TSource> source, string propertyName, string method)
4950
{
50-
// {x}
51+
// {x}
5152
var parameter = Expression.Parameter(typeof(TSource), "x");
5253
// {x.propertyName}
5354
var property = Expression.Property(parameter, propertyName);
@@ -64,7 +65,7 @@ private static IOrderedQueryable<TSource> CallGenericOrderMethod<TSource>(IQuery
6465

6566
public static IQueryable<TSource> Filter<TSource>(this IQueryable<TSource> source, FilterQuery filterQuery)
6667
{
67-
if(filterQuery == null)
68+
if (filterQuery == null)
6869
return source;
6970

7071
var concreteType = typeof(TSource);
@@ -73,21 +74,51 @@ public static IQueryable<TSource> Filter<TSource>(this IQueryable<TSource> sourc
7374
if (property == null)
7475
throw new ArgumentException($"'{filterQuery.FilteredAttribute.InternalAttributeName}' is not a valid property of '{concreteType}'");
7576

76-
// convert the incoming value to the target value type
77-
// "1" -> 1
78-
var convertedValue = Convert.ChangeType(filterQuery.PropertyValue, property.PropertyType);
79-
// {model}
80-
var parameter = Expression.Parameter(concreteType, "model");
81-
// {model.Id}
82-
var left = Expression.PropertyOrField(parameter, property.Name);
83-
// {1}
84-
var right = Expression.Constant(convertedValue, property.PropertyType);
85-
// {model.Id == 1}
86-
var body = Expression.Equal(left, right);
87-
88-
var lambda = Expression.Lambda<Func<TSource, bool>>(body, parameter);
89-
90-
return source.Where(lambda);
77+
try
78+
{
79+
// convert the incoming value to the target value type
80+
// "1" -> 1
81+
var convertedValue = Convert.ChangeType(filterQuery.PropertyValue, property.PropertyType);
82+
// {model}
83+
var parameter = Expression.Parameter(concreteType, "model");
84+
// {model.Id}
85+
var left = Expression.PropertyOrField(parameter, property.Name);
86+
// {1}
87+
var right = Expression.Constant(convertedValue, property.PropertyType);
88+
89+
var body = Expression.Equal(left, right);
90+
switch (filterQuery.FilterOperation)
91+
{
92+
case FilterOperations.eq:
93+
// {model.Id == 1}
94+
body = Expression.Equal(left, right);
95+
break;
96+
case FilterOperations.lt:
97+
// {model.Id < 1}
98+
body = Expression.LessThan(left, right);
99+
break;
100+
case FilterOperations.gt:
101+
// {model.Id > 1}
102+
body = Expression.GreaterThan(left, right);
103+
break;
104+
case FilterOperations.le:
105+
// {model.Id <= 1}
106+
body = Expression.LessThanOrEqual(left, right);
107+
break;
108+
case FilterOperations.ge:
109+
// {model.Id <= 1}
110+
body = Expression.GreaterThanOrEqual(left, right);
111+
break;
112+
}
113+
114+
var lambda = Expression.Lambda<Func<TSource, bool>>(body, parameter);
115+
116+
return source.Where(lambda);
117+
}
118+
catch (FormatException)
119+
{
120+
throw new JsonApiException("400", $"Could not cast {filterQuery.PropertyValue} to {property.PropertyType.Name}");
121+
}
91122
}
92123
}
93124
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace JsonApiDotNetCore.Internal.Query
2+
{
3+
public enum FilterOperations
4+
{
5+
eq = 0,
6+
lt = 1,
7+
gt = 2,
8+
le = 3,
9+
ge = 4
10+
}
11+
}

src/JsonApiDotNetCore/Internal/Query/FilterQuery.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ namespace JsonApiDotNetCore.Internal.Query
22
{
33
public class FilterQuery
44
{
5-
public FilterQuery(AttrAttribute filteredAttribute, string propertyValue)
5+
public FilterQuery(AttrAttribute filteredAttribute, string propertyValue, FilterOperations filterOperation)
66
{
77
FilteredAttribute = filteredAttribute;
88
PropertyValue = propertyValue;
9+
FilterOperation = filterOperation;
910
}
1011

1112
public AttrAttribute FilteredAttribute { get; set; }
1213
public string PropertyValue { get; set; }
14+
public FilterOperations FilterOperation { get; set; }
1315
}
1416
}

src/JsonApiDotNetCore/Internal/Query/QuerySet.cs

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ private void BuildQuerySet(IQueryCollection query)
3232
{
3333
if (pair.Key.StartsWith("filter"))
3434
{
35-
Filters.Add(ParseFilterQuery(pair.Key, pair.Value));
35+
Filters.AddRange(ParseFilterQuery(pair.Key, pair.Value));
3636
continue;
3737
}
3838

@@ -54,16 +54,53 @@ private void BuildQuerySet(IQueryCollection query)
5454
}
5555
}
5656

57-
private FilterQuery ParseFilterQuery(string key, string value)
57+
private List<FilterQuery> ParseFilterQuery(string key, string value)
5858
{
5959
// expected input = filter[id]=1
60+
// expected input = filter[id]=eq:1
61+
var queries = new List<FilterQuery>();
62+
6063
var propertyName = key.Split('[', ']')[1].ToProperCase();
6164
var attribute = GetAttribute(propertyName);
6265

6366
if (attribute == null)
6467
throw new JsonApiException("400", $"{propertyName} is not a valid property.");
6568

66-
return new FilterQuery(attribute, value);
69+
var values = value.Split(',');
70+
foreach(var val in values)
71+
queries.Add(ParseFilterOperation(attribute, val));
72+
73+
return queries;
74+
}
75+
76+
private FilterQuery ParseFilterOperation(AttrAttribute attribute, string value)
77+
{
78+
if(value.Length < 3)
79+
return new FilterQuery(attribute, value, FilterOperations.eq);
80+
81+
var prefix = value.Substring(0, 3);
82+
83+
if(prefix[2] != ':')
84+
return new FilterQuery(attribute, value, FilterOperations.eq);
85+
86+
// remove prefix from value
87+
value = value.Substring(3, value.Length - 3);
88+
89+
switch(prefix)
90+
{
91+
case "eq:":
92+
return new FilterQuery(attribute, value, FilterOperations.eq);
93+
case "lt:":
94+
return new FilterQuery(attribute, value, FilterOperations.lt);
95+
case "gt:":
96+
return new FilterQuery(attribute, value, FilterOperations.gt);
97+
case "le:":
98+
return new FilterQuery(attribute, value, FilterOperations.le);
99+
case "ge:":
100+
return new FilterQuery(attribute, value, FilterOperations.ge);
101+
}
102+
103+
throw new JsonApiException("400", $"Invalid filter prefix '{prefix}'");
67104
}
68105

69106
private PageQuery ParsePageQuery(string key, string value)

src/JsonApiDotNetCore/project.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"version": "0.2.6",
2+
"version": "0.2.7",
33

44
"dependencies": {
55
"Microsoft.NETCore.App": {

src/JsonApiDotNetCoreExample/Migrations/20170131150223_AddOrdinalToTodoItems.Designer.cs

Lines changed: 61 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using Microsoft.EntityFrameworkCore.Migrations;
4+
5+
namespace JsonApiDotNetCoreExample.Migrations
6+
{
7+
public partial class AddOrdinalToTodoItems : Migration
8+
{
9+
protected override void Up(MigrationBuilder migrationBuilder)
10+
{
11+
migrationBuilder.AddColumn<long>(
12+
name: "Ordinal",
13+
table: "TodoItems",
14+
nullable: false,
15+
defaultValue: 0L);
16+
}
17+
18+
protected override void Down(MigrationBuilder migrationBuilder)
19+
{
20+
migrationBuilder.DropColumn(
21+
name: "Ordinal",
22+
table: "TodoItems");
23+
}
24+
}
25+
}

src/JsonApiDotNetCoreExample/Migrations/AppDbContextModelSnapshot.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)
3737

3838
b.Property<string>("Description");
3939

40+
b.Property<long>("Ordinal");
41+
4042
b.Property<int>("OwnerId");
4143

4244
b.HasKey("Id");

src/JsonApiDotNetCoreExample/Models/TodoItem.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ public class TodoItem : Identifiable<int>
99

1010
[Attr("description")]
1111
public string Description { get; set; }
12+
13+
[Attr("ordinal")]
14+
public long Ordinal { get; set; }
1215

1316
public int OwnerId { get; set; }
1417
public virtual Person Owner { get; set; }

0 commit comments

Comments
 (0)