diff --git a/README.md b/README.md index 81ebba79bf..6bbb7b0710 100644 --- a/README.md +++ b/README.md @@ -75,3 +75,39 @@ public class Startup } } ``` + +### Development + +Restore all nuget packages with: + +```bash +dotnet restore +``` + +#### Testing + +Running tests locally requires access to a postgresql database. +If you have docker installed, this can be propped up via: + +```bash +docker run --rm --name jsonapi-dotnet-core-testing \ + -e POSTGRES_DB=JsonApiDotNetCoreExample \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -p 5432:5432 \ + postgres +``` + +And then to run the tests: + +```bash +dotnet test +``` + +#### Cleaning + +Sometimes the compiled files can be dirty / corrupt from other branches / failed builds. + +```bash +dotnet clean +``` \ No newline at end of file diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs index fecd16319d..7ae957f4a5 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs @@ -24,6 +24,12 @@ public TodoItem() [Attr("achieved-date", isFilterable: false, isSortable: false)] public DateTime? AchievedDate { get; set; } + + + [Attr("updated-date")] + public DateTime? UpdatedDate { get; set; } + + public int? OwnerId { get; set; } public int? AssigneeId { get; set; } diff --git a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs index 03f74b9e59..8afab4d45a 100644 --- a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs @@ -113,19 +113,32 @@ public static IQueryable Filter(this IQueryable sourc var concreteType = typeof(TSource); var property = concreteType.GetProperty(filterQuery.FilteredAttribute.InternalAttributeName); + var op = filterQuery.FilterOperation; if (property == null) throw new ArgumentException($"'{filterQuery.FilteredAttribute.InternalAttributeName}' is not a valid property of '{concreteType}'"); try { - if (filterQuery.FilterOperation == FilterOperations.@in || filterQuery.FilterOperation == FilterOperations.nin) + if (op == FilterOperations.@in || op == FilterOperations.nin) { string[] propertyValues = filterQuery.PropertyValue.Split(','); - var lambdaIn = ArrayContainsPredicate(propertyValues, property.Name, filterQuery.FilterOperation); + var lambdaIn = ArrayContainsPredicate(propertyValues, property.Name, op); return source.Where(lambdaIn); } + else if (op == FilterOperations.isnotnull || op == FilterOperations.isnull) { + // {model} + var parameter = Expression.Parameter(concreteType, "model"); + // {model.Id} + var left = Expression.PropertyOrField(parameter, property.Name); + var right = Expression.Constant(null); + + var body = GetFilterExpressionLambda(left, right, op); + var lambda = Expression.Lambda>(body, parameter); + + return source.Where(lambda); + } else { // convert the incoming value to the target value type // "1" -> 1 @@ -137,7 +150,7 @@ public static IQueryable Filter(this IQueryable sourc // {1} var right = Expression.Constant(convertedValue, property.PropertyType); - var body = GetFilterExpressionLambda(left, right, filterQuery.FilterOperation); + var body = GetFilterExpressionLambda(left, right, op); var lambda = Expression.Lambda>(body, parameter); @@ -204,6 +217,9 @@ public static IQueryable Filter(this IQueryable sourc } } + private static bool IsNullable(Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>); + + private static Expression GetFilterExpressionLambda(Expression left, Expression right, FilterOperations operation) { Expression body; @@ -236,6 +252,14 @@ private static Expression GetFilterExpressionLambda(Expression left, Expression case FilterOperations.ne: body = Expression.NotEqual(left, right); break; + case FilterOperations.isnotnull: + // {model.Id != null} + body = Expression.NotEqual(left, right); + break; + case FilterOperations.isnull: + // {model.Id == null} + body = Expression.Equal(left, right); + break; default: throw new JsonApiException(500, $"Unknown filter operation {operation}"); } diff --git a/src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs b/src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs index 554e6f98a4..60ae0af012 100644 --- a/src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs +++ b/src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs @@ -11,6 +11,8 @@ public enum FilterOperations like = 5, ne = 6, @in = 7, // prefix with @ to use keyword - nin = 8 + nin = 8, + isnull = 9, + isnotnull = 10 } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs index 0600fb402b..ccc9a1e870 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Net; @@ -90,6 +91,66 @@ public async Task Can_Filter_TodoItems() Assert.Equal(todoItem.Ordinal, todoItemResult.Ordinal); } + [Fact] + public async Task Can_Filter_TodoItems_Using_IsNotNull_Operator() + { + // Arrange + var todoItem = _todoItemFaker.Generate(); + todoItem.UpdatedDate = new DateTime(); + + var otherTodoItem = _todoItemFaker.Generate(); + otherTodoItem.UpdatedDate = null; + + _context.TodoItems.AddRange(new[] { todoItem, otherTodoItem }); + _context.SaveChanges(); + + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/todo-items?filter[updated-date]=isnotnull:"; + var request = new HttpRequestMessage(httpMethod, route); + + // Act + var response = await _fixture.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var todoItems = _fixture.GetService().DeserializeList(body); + + // Assert + Assert.NotEmpty(todoItems); + Assert.All(todoItems, t => Assert.NotNull(t.UpdatedDate)); + } + + [Fact] + public async Task Can_Filter_TodoItems_Using_IsNull_Operator() + { + // Arrange + var todoItem = _todoItemFaker.Generate(); + todoItem.UpdatedDate = null; + + var otherTodoItem = _todoItemFaker.Generate(); + otherTodoItem.UpdatedDate = new DateTime(); + + _context.TodoItems.AddRange(new[] { todoItem, otherTodoItem }); + _context.SaveChanges(); + + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/todo-items?filter[updated-date]=isnull:"; + var request = new HttpRequestMessage(httpMethod, route); + + // Act + var response = await _fixture.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var todoItems = _fixture.GetService().DeserializeList(body); + + // Assert + Assert.NotEmpty(todoItems); + Assert.All(todoItems, t => Assert.Null(t.UpdatedDate)); + } + [Fact] public async Task Can_Filter_TodoItems_Using_Like_Operator() {