diff --git a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs index fc0386da57..af33e883a6 100644 --- a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs @@ -1,10 +1,12 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; using System.Reflection; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Query; +using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Services; namespace JsonApiDotNetCore.Extensions @@ -297,29 +299,111 @@ private static IQueryable CallGenericWhereMethod(IQueryable Select(this IQueryable source, List columns) - { - if (columns == null || columns.Count == 0) - return source; + => CallGenericSelectMethod(source, columns); - var sourceType = source.ElementType; + private static IQueryable CallGenericSelectMethod(IQueryable source, List columns) + { + var sourceBindings = new List(); + var sourceType = typeof(TSource); + var parameter = Expression.Parameter(source.ElementType, "x"); + var sourceProperties = new List() { }; + + // Store all property names to it's own related property (name as key) + var nestedTypesAndProperties = new Dictionary>(); + foreach (var column in columns) + { + var props = column.Split('.'); + if (props.Length > 1) // Nested property + { + if (nestedTypesAndProperties.TryGetValue(props[0], out var properties) == false) + nestedTypesAndProperties.Add(props[0], new List() { nameof(Identifiable.Id), props[1] }); + else + properties.Add(props[1]); + } + else + sourceProperties.Add(props[0]); + } - var resultType = typeof(TSource); + // Bind attributes on TSource + sourceBindings = sourceProperties.Select(prop => Expression.Bind(sourceType.GetProperty(prop), Expression.PropertyOrField(parameter, prop))).ToList(); - // {model} - var parameter = Expression.Parameter(sourceType, "model"); - - var bindings = columns.Select(column => Expression.Bind( - resultType.GetProperty(column), Expression.PropertyOrField(parameter, column))); + // Bind attributes on nested types + var nestedBindings = new List(); + Expression bindExpression; + foreach (var item in nestedTypesAndProperties) + { + var nestedProperty = sourceType.GetProperty(item.Key); + var nestedPropertyType = nestedProperty.PropertyType; + // [HasMany] attribute + if (nestedPropertyType != typeof(string) && typeof(IEnumerable).IsAssignableFrom(nestedPropertyType)) + { + // Concrete type of Collection + var singleType = nestedPropertyType.GetGenericArguments().Single(); + // {y} + var nestedParameter = Expression.Parameter(singleType, "y"); + nestedBindings = item.Value.Select(prop => Expression.Bind( + singleType.GetProperty(prop), Expression.PropertyOrField(nestedParameter, prop))).ToList(); + + // { new Item() } + var newNestedExp = Expression.New(singleType); + var initNestedExp = Expression.MemberInit(newNestedExp, nestedBindings); + // { y => new Item() {Id = y.Id, Name = y.Name}} + var body = Expression.Lambda(initNestedExp, nestedParameter); + // { x.Items } + Expression propertyExpression = Expression.Property(parameter, nestedProperty.Name); + // { x.Items.Select(y => new Item() {Id = y.Id, Name = y.Name}) } + Expression selectMethod = Expression.Call( + typeof(Enumerable), + "Select", + new Type[] { singleType, singleType }, + propertyExpression, body); + + // { x.Items.Select(y => new Item() {Id = y.Id, Name = y.Name}).ToList() } + bindExpression = Expression.Call( + typeof(Enumerable), + "ToList", + new Type[] { singleType }, + selectMethod); + } + // [HasOne] attribute + else + { + // {x.Owner} + var srcBody = Expression.PropertyOrField(parameter, item.Key); + foreach (var nested in item.Value) + { + // {x.Owner.Name} + var nestedBody = Expression.PropertyOrField(srcBody, nested); + var propInfo = nestedPropertyType.GetProperty(nested); + nestedBindings.Add(Expression.Bind(propInfo, nestedBody)); + } + // { new Owner() } + var newExp = Expression.New(nestedPropertyType); + // { new Owner() { Id = x.Owner.Id, Name = x.Owner.Name }} + var newInit = Expression.MemberInit(newExp, nestedBindings); + + // Handle nullable relationships + // { Owner = x.Owner == null ? null : new Owner() {...} } + bindExpression = Expression.Condition( + Expression.Equal(srcBody, Expression.Constant(null)), + Expression.Convert(Expression.Constant(null), nestedPropertyType), + newInit + ); + } - // { new Model () { Property = model.Property } } - var body = Expression.MemberInit(Expression.New(resultType), bindings); + sourceBindings.Add(Expression.Bind(nestedProperty, bindExpression)); + nestedBindings.Clear(); + } - // { model => new TodoItem() { Property = model.Property } } - var selector = Expression.Lambda(body, parameter); + var sourceInit = Expression.MemberInit(Expression.New(sourceType), sourceBindings); + var finalBody = Expression.Lambda(sourceInit, parameter); - return source.Provider.CreateQuery( - Expression.Call(typeof(Queryable), "Select", new[] { sourceType, resultType }, - source.Expression, Expression.Quote(selector))); + return source.Provider.CreateQuery(Expression.Call( + typeof(Queryable), + "Select", + new[] { source.ElementType, typeof(TSource) }, + source.Expression, + Expression.Quote(finalBody))); } public static IQueryable PageForward(this IQueryable source, int pageSize, int pageNumber) diff --git a/src/JsonApiDotNetCore/Services/EntityResourceService.cs b/src/JsonApiDotNetCore/Services/EntityResourceService.cs index 63547eed80..53cfc7a7c1 100644 --- a/src/JsonApiDotNetCore/Services/EntityResourceService.cs +++ b/src/JsonApiDotNetCore/Services/EntityResourceService.cs @@ -247,7 +247,12 @@ private async Task GetWithRelationshipsAsync(TId id) query = _entities.Include(query, r); }); - var value = await _entities.FirstOrDefaultAsync(query); + TEntity value; + // https://github.com/aspnet/EntityFrameworkCore/issues/6573 + if (_jsonApiContext.QuerySet?.Fields?.Count > 0) + value = query.FirstOrDefault(); + else + value = await _entities.FirstOrDefaultAsync(query); return MapOut(value); } diff --git a/src/JsonApiDotNetCore/Services/QueryParser.cs b/src/JsonApiDotNetCore/Services/QueryParser.cs index f559679f33..cd839ffa73 100644 --- a/src/JsonApiDotNetCore/Services/QueryParser.cs +++ b/src/JsonApiDotNetCore/Services/QueryParser.cs @@ -182,25 +182,34 @@ protected virtual List ParseFieldsQuery(string key, string value) { // expected: fields[TYPE]=prop1,prop2 var typeName = key.Split(QueryConstants.OPEN_BRACKET, QueryConstants.CLOSE_BRACKET)[1]; + var includedFields = new List { nameof(Identifiable.Id) }; - const string ID = "Id"; - var includedFields = new List { ID }; - - // this will not support nested inclusions, it requires that the typeName is the current request type - if (string.Equals(typeName, _controllerContext.RequestEntity.EntityName, StringComparison.OrdinalIgnoreCase) == false) + var relationship = _controllerContext.RequestEntity.Relationships.SingleOrDefault(a => a.Is(typeName)); + if (relationship == default && string.Equals(typeName, _controllerContext.RequestEntity.EntityName, StringComparison.OrdinalIgnoreCase) == false) return includedFields; var fields = value.Split(QueryConstants.COMMA); foreach (var field in fields) { - var attr = _controllerContext.RequestEntity - .Attributes - .SingleOrDefault(a => a.Is(field)); + if (relationship != default) + { + var relationProperty = _options.ResourceGraph.GetContextEntity(relationship.Type); + var attr = relationProperty.Attributes.SingleOrDefault(a => a.Is(field)); + if(attr == null) + throw new JsonApiException(400, $"'{relationship.Type.Name}' does not contain '{field}'."); - if (attr == null) throw new JsonApiException(400, $"'{_controllerContext.RequestEntity.EntityName}' does not contain '{field}'."); + // e.g. "Owner.Name" + includedFields.Add(relationship.InternalRelationshipName + "." + attr.InternalAttributeName); + } + else + { + var attr = _controllerContext.RequestEntity.Attributes.SingleOrDefault(a => a.Is(field)); + if (attr == null) + throw new JsonApiException(400, $"'{_controllerContext.RequestEntity.EntityName}' does not contain '{field}'."); - var internalAttrName = attr.InternalAttributeName; - includedFields.Add(internalAttrName); + // e.g. "Name" + includedFields.Add(attr.InternalAttributeName); + } } return includedFields; diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs index 01ee8bd4d6..59bb5c590c 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Net.Http; using System.Threading.Tasks; +using Bogus; using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Models; using JsonApiDotNetCoreExample; @@ -15,6 +16,9 @@ using Newtonsoft.Json; using Xunit; using StringExtensions = JsonApiDotNetCoreExampleTests.Helpers.Extensions.StringExtensions; +using Person = JsonApiDotNetCoreExample.Models.Person; +using System.Net; +using JsonApiDotNetCore.Serialization; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec { @@ -23,11 +27,22 @@ public class SparseFieldSetTests { private TestFixture _fixture; private readonly AppDbContext _dbContext; + private Faker _personFaker; + private Faker _todoItemFaker; public SparseFieldSetTests(TestFixture fixture) { _fixture = fixture; _dbContext = fixture.GetService(); + _personFaker = new Faker() + .RuleFor(p => p.FirstName, f => f.Name.FirstName()) + .RuleFor(p => p.LastName, f => f.Name.LastName()) + .RuleFor(p => p.Age, f => f.Random.Int(20, 80)); + + _todoItemFaker = new Faker() + .RuleFor(t => t.Description, f => f.Lorem.Sentence()) + .RuleFor(t => t.Ordinal, f => f.Random.Number(1,10)) + .RuleFor(t => t.CreatedDate, f => f.Date.Past()); } [Fact] @@ -98,5 +113,129 @@ public async Task Fields_Query_Selects_Sparse_Field_Sets() Assert.Equal(todoItem.Description, deserializeBody.Data.Attributes["description"]); Assert.Equal(todoItem.CreatedDate.ToString("G"), ((DateTime)deserializeBody.Data.Attributes["created-date"]).ToString("G")); } + + [Fact] + public async Task Fields_Query_Selects_All_Fieldset_With_HasOne() + { + // arrange + var owner = _personFaker.Generate(); + var ordinal = _dbContext.TodoItems.Count(); + var todoItem = new TodoItem + { + Description = "s", + Ordinal = ordinal, + CreatedDate = DateTime.Now, + Owner = owner + }; + _dbContext.TodoItems.Add(todoItem); + _dbContext.SaveChanges(); + + var builder = new WebHostBuilder() + .UseStartup(); + var httpMethod = new HttpMethod("GET"); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var route = $"/api/v1/todo-items?include=owner&fields[owner]=first-name,age"; + var request = new HttpRequestMessage(httpMethod, route); + + // act + var response = await client.SendAsync(request); + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var deserializedTodoItems = _fixture + .GetService() + .DeserializeList(body); + + foreach(var item in deserializedTodoItems.Where(i => i.Owner != null)) + { + Assert.Null(item.Owner.LastName); + Assert.NotNull(item.Owner.FirstName); + Assert.NotEqual(0, item.Owner.Age); + } + } + + [Fact] + public async Task Fields_Query_Selects_Fieldset_With_HasOne() + { + // arrange + var owner = _personFaker.Generate(); + var todoItem = new TodoItem + { + Description = "description", + Ordinal = 1, + CreatedDate = DateTime.Now, + Owner = owner + }; + _dbContext.TodoItems.Add(todoItem); + _dbContext.SaveChanges(); + + var builder = new WebHostBuilder() + .UseStartup(); + var httpMethod = new HttpMethod("GET"); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var route = $"/api/v1/todo-items/{todoItem.Id}?include=owner&fields[owner]=first-name,age"; + var request = new HttpRequestMessage(httpMethod, route); + + // act + var response = await client.SendAsync(request); + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var deserializeBody = JsonConvert.DeserializeObject(body); + + // check owner attributes + var included = deserializeBody.Included.First(); + Assert.Equal(owner.StringId, included.Id); + Assert.Equal(owner.FirstName, included.Attributes["first-name"]); + Assert.Equal((long)owner.Age, included.Attributes["age"]); + Assert.Null(included.Attributes["last-name"]); + + } + + [Fact] + public async Task Fields_Query_Selects_Fieldset_With_HasMany() + { + // arrange + var owner = _personFaker.Generate(); + var todoItems = _todoItemFaker.Generate(2); + + owner.TodoItems = todoItems; + + _dbContext.People.Add(owner); + _dbContext.SaveChanges(); + + var builder = new WebHostBuilder() + .UseStartup(); + var httpMethod = new HttpMethod("GET"); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var route = $"/api/v1/people/{owner.Id}?include=todo-items&fields[todo-items]=description"; + var request = new HttpRequestMessage(httpMethod, route); + + // act + var response = await client.SendAsync(request); + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var deserializeBody = JsonConvert.DeserializeObject(body); + + // check owner attributes + foreach (var includedItem in deserializeBody.Included) + { + var todoItem = todoItems.FirstOrDefault(i => i.StringId == includedItem.Id); + Assert.NotNull(todoItem); + Assert.Equal(todoItem.Description, includedItem.Attributes["description"]); + Assert.Equal(default(long), includedItem.Attributes["ordinal"]); + Assert.Equal(default(DateTime), (DateTime)includedItem.Attributes["created-date"]); + } + } } } diff --git a/test/UnitTests/Services/QueryParser_Tests.cs b/test/UnitTests/Services/QueryParser_Tests.cs index baaee8f81c..c0e5752dad 100644 --- a/test/UnitTests/Services/QueryParser_Tests.cs +++ b/test/UnitTests/Services/QueryParser_Tests.cs @@ -252,7 +252,8 @@ public void Can_Parse_Fields_Query() { InternalAttributeName = internalAttrName } - } + }, + Relationships = new List() }); var queryParser = new QueryParser(_controllerContextMock.Object, new JsonApiOptions()); @@ -285,7 +286,8 @@ public void Throws_JsonApiException_If_Field_DoesNotExist() .Returns(new ContextEntity { EntityName = type, - Attributes = new List() + Attributes = new List(), + Relationships = new List() }); var queryParser = new QueryParser(_controllerContextMock.Object, new JsonApiOptions());