Skip to content

Commit c1bf919

Browse files
authored
Merge pull request json-api-dotnet#387 from NullVoxPopuli/additional-filter-operations
Additional filter operations: isnull and isnotnull
2 parents df664ab + edd0f22 commit c1bf919

File tree

5 files changed

+133
-4
lines changed

5 files changed

+133
-4
lines changed

README.md

+36
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,39 @@ public class Startup
7575
}
7676
}
7777
```
78+
79+
### Development
80+
81+
Restore all nuget packages with:
82+
83+
```bash
84+
dotnet restore
85+
```
86+
87+
#### Testing
88+
89+
Running tests locally requires access to a postgresql database.
90+
If you have docker installed, this can be propped up via:
91+
92+
```bash
93+
docker run --rm --name jsonapi-dotnet-core-testing \
94+
-e POSTGRES_DB=JsonApiDotNetCoreExample \
95+
-e POSTGRES_USER=postgres \
96+
-e POSTGRES_PASSWORD=postgres \
97+
-p 5432:5432 \
98+
postgres
99+
```
100+
101+
And then to run the tests:
102+
103+
```bash
104+
dotnet test
105+
```
106+
107+
#### Cleaning
108+
109+
Sometimes the compiled files can be dirty / corrupt from other branches / failed builds.
110+
111+
```bash
112+
dotnet clean
113+
```

src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs

+6
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ public TodoItem()
2424

2525
[Attr("achieved-date", isFilterable: false, isSortable: false)]
2626
public DateTime? AchievedDate { get; set; }
27+
28+
29+
[Attr("updated-date")]
30+
public DateTime? UpdatedDate { get; set; }
31+
32+
2733

2834
public int? OwnerId { get; set; }
2935
public int? AssigneeId { get; set; }

src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs

+27-3
Original file line numberDiff line numberDiff line change
@@ -113,19 +113,32 @@ public static IQueryable<TSource> Filter<TSource>(this IQueryable<TSource> sourc
113113

114114
var concreteType = typeof(TSource);
115115
var property = concreteType.GetProperty(filterQuery.FilteredAttribute.InternalAttributeName);
116+
var op = filterQuery.FilterOperation;
116117

117118
if (property == null)
118119
throw new ArgumentException($"'{filterQuery.FilteredAttribute.InternalAttributeName}' is not a valid property of '{concreteType}'");
119120

120121
try
121122
{
122-
if (filterQuery.FilterOperation == FilterOperations.@in || filterQuery.FilterOperation == FilterOperations.nin)
123+
if (op == FilterOperations.@in || op == FilterOperations.nin)
123124
{
124125
string[] propertyValues = filterQuery.PropertyValue.Split(',');
125-
var lambdaIn = ArrayContainsPredicate<TSource>(propertyValues, property.Name, filterQuery.FilterOperation);
126+
var lambdaIn = ArrayContainsPredicate<TSource>(propertyValues, property.Name, op);
126127

127128
return source.Where(lambdaIn);
128129
}
130+
else if (op == FilterOperations.isnotnull || op == FilterOperations.isnull) {
131+
// {model}
132+
var parameter = Expression.Parameter(concreteType, "model");
133+
// {model.Id}
134+
var left = Expression.PropertyOrField(parameter, property.Name);
135+
var right = Expression.Constant(null);
136+
137+
var body = GetFilterExpressionLambda(left, right, op);
138+
var lambda = Expression.Lambda<Func<TSource, bool>>(body, parameter);
139+
140+
return source.Where(lambda);
141+
}
129142
else
130143
{ // convert the incoming value to the target value type
131144
// "1" -> 1
@@ -137,7 +150,7 @@ public static IQueryable<TSource> Filter<TSource>(this IQueryable<TSource> sourc
137150
// {1}
138151
var right = Expression.Constant(convertedValue, property.PropertyType);
139152

140-
var body = GetFilterExpressionLambda(left, right, filterQuery.FilterOperation);
153+
var body = GetFilterExpressionLambda(left, right, op);
141154

142155
var lambda = Expression.Lambda<Func<TSource, bool>>(body, parameter);
143156

@@ -204,6 +217,9 @@ public static IQueryable<TSource> Filter<TSource>(this IQueryable<TSource> sourc
204217
}
205218
}
206219

220+
private static bool IsNullable(Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>);
221+
222+
207223
private static Expression GetFilterExpressionLambda(Expression left, Expression right, FilterOperations operation)
208224
{
209225
Expression body;
@@ -236,6 +252,14 @@ private static Expression GetFilterExpressionLambda(Expression left, Expression
236252
case FilterOperations.ne:
237253
body = Expression.NotEqual(left, right);
238254
break;
255+
case FilterOperations.isnotnull:
256+
// {model.Id != null}
257+
body = Expression.NotEqual(left, right);
258+
break;
259+
case FilterOperations.isnull:
260+
// {model.Id == null}
261+
body = Expression.Equal(left, right);
262+
break;
239263
default:
240264
throw new JsonApiException(500, $"Unknown filter operation {operation}");
241265
}

src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs

+3-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ public enum FilterOperations
1111
like = 5,
1212
ne = 6,
1313
@in = 7, // prefix with @ to use keyword
14-
nin = 8
14+
nin = 8,
15+
isnull = 9,
16+
isnotnull = 10
1517
}
1618
}

test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs

+61
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System;
12
using System.Collections.Generic;
23
using System.Linq;
34
using System.Net;
@@ -90,6 +91,66 @@ public async Task Can_Filter_TodoItems()
9091
Assert.Equal(todoItem.Ordinal, todoItemResult.Ordinal);
9192
}
9293

94+
[Fact]
95+
public async Task Can_Filter_TodoItems_Using_IsNotNull_Operator()
96+
{
97+
// Arrange
98+
var todoItem = _todoItemFaker.Generate();
99+
todoItem.UpdatedDate = new DateTime();
100+
101+
var otherTodoItem = _todoItemFaker.Generate();
102+
otherTodoItem.UpdatedDate = null;
103+
104+
_context.TodoItems.AddRange(new[] { todoItem, otherTodoItem });
105+
_context.SaveChanges();
106+
107+
var httpMethod = new HttpMethod("GET");
108+
var route = $"/api/v1/todo-items?filter[updated-date]=isnotnull:";
109+
var request = new HttpRequestMessage(httpMethod, route);
110+
111+
// Act
112+
var response = await _fixture.Client.SendAsync(request);
113+
114+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
115+
116+
var body = await response.Content.ReadAsStringAsync();
117+
var todoItems = _fixture.GetService<IJsonApiDeSerializer>().DeserializeList<TodoItem>(body);
118+
119+
// Assert
120+
Assert.NotEmpty(todoItems);
121+
Assert.All(todoItems, t => Assert.NotNull(t.UpdatedDate));
122+
}
123+
124+
[Fact]
125+
public async Task Can_Filter_TodoItems_Using_IsNull_Operator()
126+
{
127+
// Arrange
128+
var todoItem = _todoItemFaker.Generate();
129+
todoItem.UpdatedDate = null;
130+
131+
var otherTodoItem = _todoItemFaker.Generate();
132+
otherTodoItem.UpdatedDate = new DateTime();
133+
134+
_context.TodoItems.AddRange(new[] { todoItem, otherTodoItem });
135+
_context.SaveChanges();
136+
137+
var httpMethod = new HttpMethod("GET");
138+
var route = $"/api/v1/todo-items?filter[updated-date]=isnull:";
139+
var request = new HttpRequestMessage(httpMethod, route);
140+
141+
// Act
142+
var response = await _fixture.Client.SendAsync(request);
143+
144+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
145+
146+
var body = await response.Content.ReadAsStringAsync();
147+
var todoItems = _fixture.GetService<IJsonApiDeSerializer>().DeserializeList<TodoItem>(body);
148+
149+
// Assert
150+
Assert.NotEmpty(todoItems);
151+
Assert.All(todoItems, t => Assert.Null(t.UpdatedDate));
152+
}
153+
93154
[Fact]
94155
public async Task Can_Filter_TodoItems_Using_Like_Operator()
95156
{

0 commit comments

Comments
 (0)