Skip to content

Nested sparse fields #436

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Nov 13, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 101 additions & 17 deletions src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -297,29 +299,111 @@ private static IQueryable<TSource> CallGenericWhereMethod<TSource>(IQueryable<TS
}

public static IQueryable<TSource> Select<TSource>(this IQueryable<TSource> source, List<string> columns)
{
if (columns == null || columns.Count == 0)
return source;
=> CallGenericSelectMethod(source, columns);

var sourceType = source.ElementType;
private static IQueryable<TSource> CallGenericSelectMethod<TSource>(IQueryable<TSource> source, List<string> columns)
{
var sourceBindings = new List<MemberAssignment>();
var sourceType = typeof(TSource);
var parameter = Expression.Parameter(source.ElementType, "x");
var sourceProperties = new List<string>() { };

// Store all property names to it's own related property (name as key)
var nestedTypesAndProperties = new Dictionary<string, List<string>>();
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<string>() { 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<MemberAssignment>();
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<TSource>(
Expression.Call(typeof(Queryable), "Select", new[] { sourceType, resultType },
source.Expression, Expression.Quote(selector)));
return source.Provider.CreateQuery<TSource>(Expression.Call(
typeof(Queryable),
"Select",
new[] { source.ElementType, typeof(TSource) },
source.Expression,
Expression.Quote(finalBody)));
}

public static IQueryable<T> PageForward<T>(this IQueryable<T> source, int pageSize, int pageNumber)
Expand Down
7 changes: 6 additions & 1 deletion src/JsonApiDotNetCore/Services/EntityResourceService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,12 @@ private async Task<TResource> GetWithRelationshipsAsync(TId id)
query = _entities.Include(query, r);
});

var value = await _entities.FirstOrDefaultAsync(query);
TEntity value;
// https://github.com/aspnet/EntityFrameworkCore/issues/6573
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

if (_jsonApiContext.QuerySet?.Fields?.Count > 0)
value = query.FirstOrDefault();
else
value = await _entities.FirstOrDefaultAsync(query);

return MapOut(value);
}
Expand Down
31 changes: 20 additions & 11 deletions src/JsonApiDotNetCore/Services/QueryParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -182,25 +182,34 @@ protected virtual List<string> 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<string> { nameof(Identifiable.Id) };

const string ID = "Id";
var includedFields = new List<string> { 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
{
Expand All @@ -23,11 +27,22 @@ public class SparseFieldSetTests
{
private TestFixture<TestStartup> _fixture;
private readonly AppDbContext _dbContext;
private Faker<Person> _personFaker;
private Faker<TodoItem> _todoItemFaker;

public SparseFieldSetTests(TestFixture<TestStartup> fixture)
{
_fixture = fixture;
_dbContext = fixture.GetService<AppDbContext>();
_personFaker = new Faker<Person>()
.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<TodoItem>()
.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]
Expand Down Expand Up @@ -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<Startup>();
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<IJsonApiDeSerializer>()
.DeserializeList<TodoItem>(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<Startup>();
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<Document>(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<Startup>();
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<Document>(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"]);
}
}
}
}
6 changes: 4 additions & 2 deletions test/UnitTests/Services/QueryParser_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,8 @@ public void Can_Parse_Fields_Query()
{
InternalAttributeName = internalAttrName
}
}
},
Relationships = new List<RelationshipAttribute>()
});

var queryParser = new QueryParser(_controllerContextMock.Object, new JsonApiOptions());
Expand Down Expand Up @@ -285,7 +286,8 @@ public void Throws_JsonApiException_If_Field_DoesNotExist()
.Returns(new ContextEntity
{
EntityName = type,
Attributes = new List<AttrAttribute>()
Attributes = new List<AttrAttribute>(),
Relationships = new List<RelationshipAttribute>()
});

var queryParser = new QueryParser(_controllerContextMock.Object, new JsonApiOptions());
Expand Down