diff --git a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs index aaff9afb7d..f7b0a5e960 100644 --- a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs +++ b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs @@ -56,10 +56,10 @@ public virtual IQueryable Filter(IQueryable entities, FilterQ if(filterQuery == null) return entities; - var attributeFilterQuery = new AttrFilterQuery(_jsonApiContext, filterQuery); - - return entities - .Filter(attributeFilterQuery); + if(filterQuery.IsAttributeOfRelationship) + return entities.Filter(new RelatedAttrFilterQuery(_jsonApiContext, filterQuery)); + else + return entities.Filter(new AttrFilterQuery(_jsonApiContext, filterQuery)); } public virtual IQueryable Sort(IQueryable entities, List sortQueries) diff --git a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs index 1ffeee4d5a..62313e3ad9 100644 --- a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs @@ -86,36 +86,7 @@ public static IQueryable Filter(this IQueryable sourc // {1} var right = Expression.Constant(convertedValue, property.PropertyType); - Expression body; - switch (filterQuery.FilterOperation) - { - case FilterOperations.eq: - // {model.Id == 1} - body = Expression.Equal(left, right); - break; - case FilterOperations.lt: - // {model.Id < 1} - body = Expression.LessThan(left, right); - break; - case FilterOperations.gt: - // {model.Id > 1} - body = Expression.GreaterThan(left, right); - break; - case FilterOperations.le: - // {model.Id <= 1} - body = Expression.LessThanOrEqual(left, right); - break; - case FilterOperations.ge: - // {model.Id <= 1} - body = Expression.GreaterThanOrEqual(left, right); - break; - case FilterOperations.like: - // {model.Id <= 1} - body = Expression.Call(left, "Contains", null, right); - break; - default: - throw new JsonApiException("500", $"Unknown filter operation {filterQuery.FilterOperation}"); - } + var body = GetFilterExpressionLambda(left, right, filterQuery.FilterOperation); var lambda = Expression.Lambda>(body, parameter); @@ -126,27 +97,109 @@ public static IQueryable Filter(this IQueryable sourc throw new JsonApiException("400", $"Could not cast {filterQuery.PropertyValue} to {property.PropertyType.Name}"); } } + + public static IQueryable Filter(this IQueryable source, RelatedAttrFilterQuery filterQuery) + { + if (filterQuery == null) + return source; + + var concreteType = typeof(TSource); + var relation = concreteType.GetProperty(filterQuery.FilteredRelationship.InternalRelationshipName); + if (relation == null) + throw new ArgumentException($"'{filterQuery.FilteredRelationship.InternalRelationshipName}' is not a valid relationship of '{concreteType}'"); + + var relatedType = filterQuery.FilteredRelationship.Type; + var relatedAttr = relatedType.GetProperty(filterQuery.FilteredAttribute.InternalAttributeName); + if (relatedAttr == null) + throw new ArgumentException($"'{filterQuery.FilteredAttribute.InternalAttributeName}' is not a valid attribute of '{filterQuery.FilteredRelationship.InternalRelationshipName}'"); + + try + { + // convert the incoming value to the target value type + // "1" -> 1 + var convertedValue = TypeHelper.ConvertType(filterQuery.PropertyValue, relatedAttr.PropertyType); + // {model} + var parameter = Expression.Parameter(concreteType, "model"); + + // {model.Relationship} + var leftRelationship = Expression.PropertyOrField(parameter, relation.Name); + + // {model.Relationship.Attr} + var left = Expression.PropertyOrField(leftRelationship, relatedAttr.Name); + + // {1} + var right = Expression.Constant(convertedValue, relatedAttr.PropertyType); + + var body = GetFilterExpressionLambda(left, right, filterQuery.FilterOperation); + + var lambda = Expression.Lambda>(body, parameter); + + return source.Where(lambda); + } + catch (FormatException) + { + throw new JsonApiException("400", $"Could not cast {filterQuery.PropertyValue} to {relatedAttr.PropertyType.Name}"); + } + } + + private static Expression GetFilterExpressionLambda(Expression left, Expression right, FilterOperations operation) + { + Expression body; + switch (operation) + { + case FilterOperations.eq: + // {model.Id == 1} + body = Expression.Equal(left, right); + break; + case FilterOperations.lt: + // {model.Id < 1} + body = Expression.LessThan(left, right); + break; + case FilterOperations.gt: + // {model.Id > 1} + body = Expression.GreaterThan(left, right); + break; + case FilterOperations.le: + // {model.Id <= 1} + body = Expression.LessThanOrEqual(left, right); + break; + case FilterOperations.ge: + // {model.Id <= 1} + body = Expression.GreaterThanOrEqual(left, right); + break; + case FilterOperations.like: + // {model.Id <= 1} + body = Expression.Call(left, "Contains", null, right); + break; + default: + throw new JsonApiException("500", $"Unknown filter operation {operation}"); + } + + return body; + } + + public static IQueryable Select(this IQueryable source, IEnumerable columns) { - if(columns == null || columns.Count() == 0) + if (columns == null || columns.Count() == 0) return source; var sourceType = source.ElementType; - + var resultType = typeof(TSource); // {model} var parameter = Expression.Parameter(sourceType, "model"); - + var bindings = columns.Select(column => Expression.Bind( resultType.GetProperty(column), Expression.PropertyOrField(parameter, column))); - + // { new Model () { Property = model.Property } } var body = Expression.MemberInit(Expression.New(resultType), bindings); - + // { model => new TodoItem() { Property = model.Property } } var selector = Expression.Lambda(body, parameter); - + return source.Provider.CreateQuery( Expression.Call(typeof(Queryable), "Select", new Type[] { sourceType, resultType }, source.Expression, Expression.Quote(selector))); diff --git a/src/JsonApiDotNetCore/Internal/Query/AttrFilterQuery.cs b/src/JsonApiDotNetCore/Internal/Query/AttrFilterQuery.cs index 1a691d1d15..f45d384d72 100644 --- a/src/JsonApiDotNetCore/Internal/Query/AttrFilterQuery.cs +++ b/src/JsonApiDotNetCore/Internal/Query/AttrFilterQuery.cs @@ -1,51 +1,39 @@ -using System; using System.Linq; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Services; namespace JsonApiDotNetCore.Internal.Query { - public class AttrFilterQuery - { - private readonly IJsonApiContext _jsonApiContext; - - public AttrFilterQuery( - IJsonApiContext jsonApiCopntext, - FilterQuery filterQuery) - { - _jsonApiContext = jsonApiCopntext; - - var attribute = GetAttribute(filterQuery.Key); - - if (attribute == null) - throw new JsonApiException("400", $"{filterQuery.Key} is not a valid property."); - - FilteredAttribute = attribute; - PropertyValue = filterQuery.Value; - FilterOperation = GetFilterOperation(filterQuery.Operation); - } - - public AttrAttribute FilteredAttribute { get; set; } - public string PropertyValue { get; set; } - public FilterOperations FilterOperation { get; set; } - - private FilterOperations GetFilterOperation(string prefix) - { - if (prefix.Length == 0) return FilterOperations.eq; - - FilterOperations opertion; - if (!Enum.TryParse(prefix, out opertion)) - throw new JsonApiException("400", $"Invalid filter prefix '{prefix}'"); - - return opertion; - } - - private AttrAttribute GetAttribute(string propertyName) + public class AttrFilterQuery : BaseFilterQuery { - return _jsonApiContext.RequestEntity.Attributes - .FirstOrDefault(attr => - attr.InternalAttributeName.ToLower() == propertyName.ToLower() - ); + private readonly IJsonApiContext _jsonApiContext; + + public AttrFilterQuery( + IJsonApiContext jsonApiCopntext, + FilterQuery filterQuery) + { + _jsonApiContext = jsonApiCopntext; + + var attribute = GetAttribute(filterQuery.Key); + + if (attribute == null) + throw new JsonApiException("400", $"{filterQuery.Key} is not a valid property."); + + FilteredAttribute = attribute; + PropertyValue = filterQuery.Value; + FilterOperation = GetFilterOperation(filterQuery.Operation); + } + + public AttrAttribute FilteredAttribute { get; set; } + public string PropertyValue { get; set; } + public FilterOperations FilterOperation { get; set; } + + private AttrAttribute GetAttribute(string propertyName) + { + return _jsonApiContext.RequestEntity.Attributes + .FirstOrDefault(attr => + attr.InternalAttributeName.ToLower() == propertyName.ToLower() + ); + } } - } } \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Internal/Query/BaseFilterQuery.cs b/src/JsonApiDotNetCore/Internal/Query/BaseFilterQuery.cs new file mode 100644 index 0000000000..fdb2abd4ae --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Query/BaseFilterQuery.cs @@ -0,0 +1,18 @@ +using System; + +namespace JsonApiDotNetCore.Internal.Query +{ + public class BaseFilterQuery + { + protected FilterOperations GetFilterOperation(string prefix) + { + if (prefix.Length == 0) return FilterOperations.eq; + + FilterOperations opertion; + if (!Enum.TryParse(prefix, out opertion)) + throw new JsonApiException("400", $"Invalid filter prefix '{prefix}'"); + + return opertion; + } + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Internal/Query/FilterQuery.cs b/src/JsonApiDotNetCore/Internal/Query/FilterQuery.cs index 11ad90281c..7f4f1a40c6 100644 --- a/src/JsonApiDotNetCore/Internal/Query/FilterQuery.cs +++ b/src/JsonApiDotNetCore/Internal/Query/FilterQuery.cs @@ -12,5 +12,6 @@ public FilterQuery(string key, string value, string operation) public string Key { get; set; } public string Value { get; set; } public string Operation { get; set; } + public bool IsAttributeOfRelationship => Key.Contains("."); } } \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Internal/Query/RelatedAttrFilterQuery.cs b/src/JsonApiDotNetCore/Internal/Query/RelatedAttrFilterQuery.cs new file mode 100644 index 0000000000..7fb93b8d46 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Query/RelatedAttrFilterQuery.cs @@ -0,0 +1,51 @@ +using System.Linq; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.Internal.Query +{ + public class RelatedAttrFilterQuery : BaseFilterQuery + { + private readonly IJsonApiContext _jsonApiContext; + + public RelatedAttrFilterQuery( + IJsonApiContext jsonApiCopntext, + FilterQuery filterQuery) + { + _jsonApiContext = jsonApiCopntext; + + var relationshipArray = filterQuery.Key.Split('.'); + + var relationship = GetRelationship(relationshipArray[0]); + if (relationship == null) + throw new JsonApiException("400", $"{relationshipArray[0]} is not a valid relationship."); + + var attribute = GetAttribute(relationship, relationshipArray[1]); + if (attribute == null) + throw new JsonApiException("400", $"{relationshipArray[1]} is not a valid attribute on {relationshipArray[0]}."); + + FilteredRelationship = relationship; + FilteredAttribute = attribute; + PropertyValue = filterQuery.Value; + FilterOperation = GetFilterOperation(filterQuery.Operation); + } + + public AttrAttribute FilteredAttribute { get; set; } + public string PropertyValue { get; set; } + public FilterOperations FilterOperation { get; set; } + public RelationshipAttribute FilteredRelationship { get; private set; } + + private RelationshipAttribute GetRelationship(string propertyName) + { + return _jsonApiContext.RequestEntity.Relationships + .FirstOrDefault(r => r.InternalRelationshipName.ToLower() == propertyName.ToLower()); + } + + private AttrAttribute GetAttribute(RelationshipAttribute relationship, string attribute) + { + var relatedContextExntity = _jsonApiContext.ContextGraph.GetContextEntity(relationship.Type); + return relatedContextExntity.Attributes + .FirstOrDefault(a => a.InternalAttributeName.ToLower() == attribute.ToLower()); + } + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index b2d2bd4a6c..99a0884eeb 100755 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -1,6 +1,6 @@  - 1.3.1 + 1.3.2 netcoreapp1.0 JsonApiDotNetCore JsonApiDotNetCore diff --git a/src/JsonApiDotNetCoreExample/Models/Person.cs b/src/JsonApiDotNetCoreExample/Models/Person.cs index 52b67347e9..04992a40a6 100644 --- a/src/JsonApiDotNetCoreExample/Models/Person.cs +++ b/src/JsonApiDotNetCoreExample/Models/Person.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Services; diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs index 5c10196d1f..3405eab431 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs @@ -2,19 +2,19 @@ using System.Net.Http; using System.Threading.Tasks; using DotNetCoreDocs; -using DotNetCoreDocs.Models; using DotNetCoreDocs.Writers; using JsonApiDotNetCoreExample; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; -using Newtonsoft.Json; using Xunit; -using JsonApiDotNetCore.Internal; using JsonApiDotNetCoreExample.Data; using Bogus; using JsonApiDotNetCoreExample.Models; using JsonApiDotNetCore.Serialization; using System.Linq; +using Person = JsonApiDotNetCoreExample.Models.Person; +using Newtonsoft.Json; +using JsonApiDotNetCore.Models; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec { @@ -23,13 +23,18 @@ public class AttributeFilterTests { private DocsFixture _fixture; private Faker _todoItemFaker; - + private readonly Faker _personFaker; + public AttributeFilterTests(DocsFixture fixture) { _fixture = fixture; _todoItemFaker = new Faker() .RuleFor(t => t.Description, f => f.Lorem.Sentence()) .RuleFor(t => t.Ordinal, f => f.Random.Number()); + + _personFaker = new Faker() + .RuleFor(p => p.FirstName, f => f.Name.FirstName()) + .RuleFor(p => p.LastName, f => f.Name.LastName()); } [Fact] @@ -63,5 +68,39 @@ public async Task Can_Filter_On_Guid_Properties() Assert.Equal(todoItem.Id, todoItemResponse.Id); Assert.Equal(todoItem.GuidProperty, todoItemResponse.GuidProperty); } + + + [Fact] + public async Task Can_Filter_On_Related_Attrs() + { + // arrange + var context = _fixture.GetService(); + var person = _personFaker.Generate(); + var todoItem = _todoItemFaker.Generate(); + todoItem.Owner = person; + context.TodoItems.Add(todoItem); + await context.SaveChangesAsync(); + + var builder = new WebHostBuilder() + .UseStartup(); + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/todo-items?include=owner&filter[owner.first-name]={person.FirstName}"; + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(httpMethod, route); + + // act + var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + var documents = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + var included = documents.Included; + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(included); + Assert.NotEmpty(included); + foreach(var item in included) + Assert.Equal(person.FirstName, item.Attributes["first-name"]); + } } }