Skip to content

Commit c8adc9a

Browse files
authored
Merge pull request #436 from milosloub/feat/#434
Nested sparse fields
2 parents 90d32df + 3722aca commit c8adc9a

File tree

5 files changed

+270
-31
lines changed

5 files changed

+270
-31
lines changed

Diff for: src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs

+101-17
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
using System;
2+
using System.Collections;
23
using System.Collections.Generic;
34
using System.Linq;
45
using System.Linq.Expressions;
56
using System.Reflection;
67
using JsonApiDotNetCore.Internal;
78
using JsonApiDotNetCore.Internal.Query;
9+
using JsonApiDotNetCore.Models;
810
using JsonApiDotNetCore.Services;
911

1012
namespace JsonApiDotNetCore.Extensions
@@ -297,29 +299,111 @@ private static IQueryable<TSource> CallGenericWhereMethod<TSource>(IQueryable<TS
297299
}
298300

299301
public static IQueryable<TSource> Select<TSource>(this IQueryable<TSource> source, List<string> columns)
300-
{
301-
if (columns == null || columns.Count == 0)
302-
return source;
302+
=> CallGenericSelectMethod(source, columns);
303303

304-
var sourceType = source.ElementType;
304+
private static IQueryable<TSource> CallGenericSelectMethod<TSource>(IQueryable<TSource> source, List<string> columns)
305+
{
306+
var sourceBindings = new List<MemberAssignment>();
307+
var sourceType = typeof(TSource);
308+
var parameter = Expression.Parameter(source.ElementType, "x");
309+
var sourceProperties = new List<string>() { };
310+
311+
// Store all property names to it's own related property (name as key)
312+
var nestedTypesAndProperties = new Dictionary<string, List<string>>();
313+
foreach (var column in columns)
314+
{
315+
var props = column.Split('.');
316+
if (props.Length > 1) // Nested property
317+
{
318+
if (nestedTypesAndProperties.TryGetValue(props[0], out var properties) == false)
319+
nestedTypesAndProperties.Add(props[0], new List<string>() { nameof(Identifiable.Id), props[1] });
320+
else
321+
properties.Add(props[1]);
322+
}
323+
else
324+
sourceProperties.Add(props[0]);
325+
}
305326

306-
var resultType = typeof(TSource);
327+
// Bind attributes on TSource
328+
sourceBindings = sourceProperties.Select(prop => Expression.Bind(sourceType.GetProperty(prop), Expression.PropertyOrField(parameter, prop))).ToList();
307329

308-
// {model}
309-
var parameter = Expression.Parameter(sourceType, "model");
310-
311-
var bindings = columns.Select(column => Expression.Bind(
312-
resultType.GetProperty(column), Expression.PropertyOrField(parameter, column)));
330+
// Bind attributes on nested types
331+
var nestedBindings = new List<MemberAssignment>();
332+
Expression bindExpression;
333+
foreach (var item in nestedTypesAndProperties)
334+
{
335+
var nestedProperty = sourceType.GetProperty(item.Key);
336+
var nestedPropertyType = nestedProperty.PropertyType;
337+
// [HasMany] attribute
338+
if (nestedPropertyType != typeof(string) && typeof(IEnumerable).IsAssignableFrom(nestedPropertyType))
339+
{
340+
// Concrete type of Collection
341+
var singleType = nestedPropertyType.GetGenericArguments().Single();
342+
// {y}
343+
var nestedParameter = Expression.Parameter(singleType, "y");
344+
nestedBindings = item.Value.Select(prop => Expression.Bind(
345+
singleType.GetProperty(prop), Expression.PropertyOrField(nestedParameter, prop))).ToList();
346+
347+
// { new Item() }
348+
var newNestedExp = Expression.New(singleType);
349+
var initNestedExp = Expression.MemberInit(newNestedExp, nestedBindings);
350+
// { y => new Item() {Id = y.Id, Name = y.Name}}
351+
var body = Expression.Lambda(initNestedExp, nestedParameter);
352+
// { x.Items }
353+
Expression propertyExpression = Expression.Property(parameter, nestedProperty.Name);
354+
// { x.Items.Select(y => new Item() {Id = y.Id, Name = y.Name}) }
355+
Expression selectMethod = Expression.Call(
356+
typeof(Enumerable),
357+
"Select",
358+
new Type[] { singleType, singleType },
359+
propertyExpression, body);
360+
361+
// { x.Items.Select(y => new Item() {Id = y.Id, Name = y.Name}).ToList() }
362+
bindExpression = Expression.Call(
363+
typeof(Enumerable),
364+
"ToList",
365+
new Type[] { singleType },
366+
selectMethod);
367+
}
368+
// [HasOne] attribute
369+
else
370+
{
371+
// {x.Owner}
372+
var srcBody = Expression.PropertyOrField(parameter, item.Key);
373+
foreach (var nested in item.Value)
374+
{
375+
// {x.Owner.Name}
376+
var nestedBody = Expression.PropertyOrField(srcBody, nested);
377+
var propInfo = nestedPropertyType.GetProperty(nested);
378+
nestedBindings.Add(Expression.Bind(propInfo, nestedBody));
379+
}
380+
// { new Owner() }
381+
var newExp = Expression.New(nestedPropertyType);
382+
// { new Owner() { Id = x.Owner.Id, Name = x.Owner.Name }}
383+
var newInit = Expression.MemberInit(newExp, nestedBindings);
384+
385+
// Handle nullable relationships
386+
// { Owner = x.Owner == null ? null : new Owner() {...} }
387+
bindExpression = Expression.Condition(
388+
Expression.Equal(srcBody, Expression.Constant(null)),
389+
Expression.Convert(Expression.Constant(null), nestedPropertyType),
390+
newInit
391+
);
392+
}
313393

314-
// { new Model () { Property = model.Property } }
315-
var body = Expression.MemberInit(Expression.New(resultType), bindings);
394+
sourceBindings.Add(Expression.Bind(nestedProperty, bindExpression));
395+
nestedBindings.Clear();
396+
}
316397

317-
// { model => new TodoItem() { Property = model.Property } }
318-
var selector = Expression.Lambda(body, parameter);
398+
var sourceInit = Expression.MemberInit(Expression.New(sourceType), sourceBindings);
399+
var finalBody = Expression.Lambda(sourceInit, parameter);
319400

320-
return source.Provider.CreateQuery<TSource>(
321-
Expression.Call(typeof(Queryable), "Select", new[] { sourceType, resultType },
322-
source.Expression, Expression.Quote(selector)));
401+
return source.Provider.CreateQuery<TSource>(Expression.Call(
402+
typeof(Queryable),
403+
"Select",
404+
new[] { source.ElementType, typeof(TSource) },
405+
source.Expression,
406+
Expression.Quote(finalBody)));
323407
}
324408

325409
public static IQueryable<T> PageForward<T>(this IQueryable<T> source, int pageSize, int pageNumber)

Diff for: src/JsonApiDotNetCore/Services/EntityResourceService.cs

+6-1
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,12 @@ private async Task<TResource> GetWithRelationshipsAsync(TId id)
247247
query = _entities.Include(query, r);
248248
});
249249

250-
var value = await _entities.FirstOrDefaultAsync(query);
250+
TEntity value;
251+
// https://github.com/aspnet/EntityFrameworkCore/issues/6573
252+
if (_jsonApiContext.QuerySet?.Fields?.Count > 0)
253+
value = query.FirstOrDefault();
254+
else
255+
value = await _entities.FirstOrDefaultAsync(query);
251256

252257
return MapOut(value);
253258
}

Diff for: src/JsonApiDotNetCore/Services/QueryParser.cs

+20-11
Original file line numberDiff line numberDiff line change
@@ -182,25 +182,34 @@ protected virtual List<string> ParseFieldsQuery(string key, string value)
182182
{
183183
// expected: fields[TYPE]=prop1,prop2
184184
var typeName = key.Split(QueryConstants.OPEN_BRACKET, QueryConstants.CLOSE_BRACKET)[1];
185+
var includedFields = new List<string> { nameof(Identifiable.Id) };
185186

186-
const string ID = "Id";
187-
var includedFields = new List<string> { ID };
188-
189-
// this will not support nested inclusions, it requires that the typeName is the current request type
190-
if (string.Equals(typeName, _controllerContext.RequestEntity.EntityName, StringComparison.OrdinalIgnoreCase) == false)
187+
var relationship = _controllerContext.RequestEntity.Relationships.SingleOrDefault(a => a.Is(typeName));
188+
if (relationship == default && string.Equals(typeName, _controllerContext.RequestEntity.EntityName, StringComparison.OrdinalIgnoreCase) == false)
191189
return includedFields;
192190

193191
var fields = value.Split(QueryConstants.COMMA);
194192
foreach (var field in fields)
195193
{
196-
var attr = _controllerContext.RequestEntity
197-
.Attributes
198-
.SingleOrDefault(a => a.Is(field));
194+
if (relationship != default)
195+
{
196+
var relationProperty = _options.ResourceGraph.GetContextEntity(relationship.Type);
197+
var attr = relationProperty.Attributes.SingleOrDefault(a => a.Is(field));
198+
if(attr == null)
199+
throw new JsonApiException(400, $"'{relationship.Type.Name}' does not contain '{field}'.");
199200

200-
if (attr == null) throw new JsonApiException(400, $"'{_controllerContext.RequestEntity.EntityName}' does not contain '{field}'.");
201+
// e.g. "Owner.Name"
202+
includedFields.Add(relationship.InternalRelationshipName + "." + attr.InternalAttributeName);
203+
}
204+
else
205+
{
206+
var attr = _controllerContext.RequestEntity.Attributes.SingleOrDefault(a => a.Is(field));
207+
if (attr == null)
208+
throw new JsonApiException(400, $"'{_controllerContext.RequestEntity.EntityName}' does not contain '{field}'.");
201209

202-
var internalAttrName = attr.InternalAttributeName;
203-
includedFields.Add(internalAttrName);
210+
// e.g. "Name"
211+
includedFields.Add(attr.InternalAttributeName);
212+
}
204213
}
205214

206215
return includedFields;

Diff for: test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs

+139
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.Linq;
44
using System.Net.Http;
55
using System.Threading.Tasks;
6+
using Bogus;
67
using JsonApiDotNetCore.Extensions;
78
using JsonApiDotNetCore.Models;
89
using JsonApiDotNetCoreExample;
@@ -15,6 +16,9 @@
1516
using Newtonsoft.Json;
1617
using Xunit;
1718
using StringExtensions = JsonApiDotNetCoreExampleTests.Helpers.Extensions.StringExtensions;
19+
using Person = JsonApiDotNetCoreExample.Models.Person;
20+
using System.Net;
21+
using JsonApiDotNetCore.Serialization;
1822

1923
namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec
2024
{
@@ -23,11 +27,22 @@ public class SparseFieldSetTests
2327
{
2428
private TestFixture<TestStartup> _fixture;
2529
private readonly AppDbContext _dbContext;
30+
private Faker<Person> _personFaker;
31+
private Faker<TodoItem> _todoItemFaker;
2632

2733
public SparseFieldSetTests(TestFixture<TestStartup> fixture)
2834
{
2935
_fixture = fixture;
3036
_dbContext = fixture.GetService<AppDbContext>();
37+
_personFaker = new Faker<Person>()
38+
.RuleFor(p => p.FirstName, f => f.Name.FirstName())
39+
.RuleFor(p => p.LastName, f => f.Name.LastName())
40+
.RuleFor(p => p.Age, f => f.Random.Int(20, 80));
41+
42+
_todoItemFaker = new Faker<TodoItem>()
43+
.RuleFor(t => t.Description, f => f.Lorem.Sentence())
44+
.RuleFor(t => t.Ordinal, f => f.Random.Number(1,10))
45+
.RuleFor(t => t.CreatedDate, f => f.Date.Past());
3146
}
3247

3348
[Fact]
@@ -98,5 +113,129 @@ public async Task Fields_Query_Selects_Sparse_Field_Sets()
98113
Assert.Equal(todoItem.Description, deserializeBody.Data.Attributes["description"]);
99114
Assert.Equal(todoItem.CreatedDate.ToString("G"), ((DateTime)deserializeBody.Data.Attributes["created-date"]).ToString("G"));
100115
}
116+
117+
[Fact]
118+
public async Task Fields_Query_Selects_All_Fieldset_With_HasOne()
119+
{
120+
// arrange
121+
var owner = _personFaker.Generate();
122+
var ordinal = _dbContext.TodoItems.Count();
123+
var todoItem = new TodoItem
124+
{
125+
Description = "s",
126+
Ordinal = ordinal,
127+
CreatedDate = DateTime.Now,
128+
Owner = owner
129+
};
130+
_dbContext.TodoItems.Add(todoItem);
131+
_dbContext.SaveChanges();
132+
133+
var builder = new WebHostBuilder()
134+
.UseStartup<Startup>();
135+
var httpMethod = new HttpMethod("GET");
136+
var server = new TestServer(builder);
137+
var client = server.CreateClient();
138+
139+
var route = $"/api/v1/todo-items?include=owner&fields[owner]=first-name,age";
140+
var request = new HttpRequestMessage(httpMethod, route);
141+
142+
// act
143+
var response = await client.SendAsync(request);
144+
145+
// assert
146+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
147+
var body = await response.Content.ReadAsStringAsync();
148+
var deserializedTodoItems = _fixture
149+
.GetService<IJsonApiDeSerializer>()
150+
.DeserializeList<TodoItem>(body);
151+
152+
foreach(var item in deserializedTodoItems.Where(i => i.Owner != null))
153+
{
154+
Assert.Null(item.Owner.LastName);
155+
Assert.NotNull(item.Owner.FirstName);
156+
Assert.NotEqual(0, item.Owner.Age);
157+
}
158+
}
159+
160+
[Fact]
161+
public async Task Fields_Query_Selects_Fieldset_With_HasOne()
162+
{
163+
// arrange
164+
var owner = _personFaker.Generate();
165+
var todoItem = new TodoItem
166+
{
167+
Description = "description",
168+
Ordinal = 1,
169+
CreatedDate = DateTime.Now,
170+
Owner = owner
171+
};
172+
_dbContext.TodoItems.Add(todoItem);
173+
_dbContext.SaveChanges();
174+
175+
var builder = new WebHostBuilder()
176+
.UseStartup<Startup>();
177+
var httpMethod = new HttpMethod("GET");
178+
var server = new TestServer(builder);
179+
var client = server.CreateClient();
180+
181+
var route = $"/api/v1/todo-items/{todoItem.Id}?include=owner&fields[owner]=first-name,age";
182+
var request = new HttpRequestMessage(httpMethod, route);
183+
184+
// act
185+
var response = await client.SendAsync(request);
186+
187+
// assert
188+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
189+
var body = await response.Content.ReadAsStringAsync();
190+
var deserializeBody = JsonConvert.DeserializeObject<Document>(body);
191+
192+
// check owner attributes
193+
var included = deserializeBody.Included.First();
194+
Assert.Equal(owner.StringId, included.Id);
195+
Assert.Equal(owner.FirstName, included.Attributes["first-name"]);
196+
Assert.Equal((long)owner.Age, included.Attributes["age"]);
197+
Assert.Null(included.Attributes["last-name"]);
198+
199+
}
200+
201+
[Fact]
202+
public async Task Fields_Query_Selects_Fieldset_With_HasMany()
203+
{
204+
// arrange
205+
var owner = _personFaker.Generate();
206+
var todoItems = _todoItemFaker.Generate(2);
207+
208+
owner.TodoItems = todoItems;
209+
210+
_dbContext.People.Add(owner);
211+
_dbContext.SaveChanges();
212+
213+
var builder = new WebHostBuilder()
214+
.UseStartup<Startup>();
215+
var httpMethod = new HttpMethod("GET");
216+
var server = new TestServer(builder);
217+
var client = server.CreateClient();
218+
219+
var route = $"/api/v1/people/{owner.Id}?include=todo-items&fields[todo-items]=description";
220+
var request = new HttpRequestMessage(httpMethod, route);
221+
222+
// act
223+
var response = await client.SendAsync(request);
224+
225+
// assert
226+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
227+
var body = await response.Content.ReadAsStringAsync();
228+
var deserializeBody = JsonConvert.DeserializeObject<Document>(body);
229+
230+
// check owner attributes
231+
foreach (var includedItem in deserializeBody.Included)
232+
{
233+
var todoItem = todoItems.FirstOrDefault(i => i.StringId == includedItem.Id);
234+
Assert.NotNull(todoItem);
235+
Assert.Equal(todoItem.Description, includedItem.Attributes["description"]);
236+
Assert.Equal(default(long), includedItem.Attributes["ordinal"]);
237+
Assert.Equal(default(DateTime), (DateTime)includedItem.Attributes["created-date"]);
238+
}
239+
}
101240
}
102241
}

Diff for: test/UnitTests/Services/QueryParser_Tests.cs

+4-2
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,8 @@ public void Can_Parse_Fields_Query()
252252
{
253253
InternalAttributeName = internalAttrName
254254
}
255-
}
255+
},
256+
Relationships = new List<RelationshipAttribute>()
256257
});
257258

258259
var queryParser = new QueryParser(_controllerContextMock.Object, new JsonApiOptions());
@@ -285,7 +286,8 @@ public void Throws_JsonApiException_If_Field_DoesNotExist()
285286
.Returns(new ContextEntity
286287
{
287288
EntityName = type,
288-
Attributes = new List<AttrAttribute>()
289+
Attributes = new List<AttrAttribute>(),
290+
Relationships = new List<RelationshipAttribute>()
289291
});
290292

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

0 commit comments

Comments
 (0)