Skip to content

Commit 135853b

Browse files
committed
Merge branch 'fix/#445' of github.com:roblankey/JsonApiDotNetCore into fix/#445
2 parents a7a22bb + 6bc8187 commit 135853b

File tree

9 files changed

+341
-33
lines changed

9 files changed

+341
-33
lines changed

src/JsonApiDotNetCore/Builders/DocumentBuilder.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,8 @@ private List<ResourceObject> IncludeRelationshipChain(
218218
throw new JsonApiException(400, $"{parentEntity.EntityName} does not contain relationship {requestedRelationship}");
219219

220220
var navigationEntity = _jsonApiContext.ResourceGraph.GetRelationshipValue(parentResource, relationship);
221+
if(navigationEntity == null)
222+
return included;
221223
if (navigationEntity is IEnumerable hasManyNavigationEntity)
222224
{
223225
foreach (IIdentifiable includedEntity in hasManyNavigationEntity)

src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs

Lines changed: 101 additions & 17 deletions
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)

src/JsonApiDotNetCore/Internal/Query/BaseAttrQuery.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using JsonApiDotNetCore.Models;
22
using JsonApiDotNetCore.Services;
3+
using System;
34
using System.Linq;
45

56
namespace JsonApiDotNetCore.Internal.Query
@@ -15,7 +16,21 @@ public abstract class BaseAttrQuery
1516

1617
public BaseAttrQuery(IJsonApiContext jsonApiContext, BaseQuery baseQuery)
1718
{
18-
_jsonApiContext = jsonApiContext;
19+
_jsonApiContext = jsonApiContext ?? throw new ArgumentNullException(nameof(jsonApiContext));
20+
21+
if(_jsonApiContext.RequestEntity == null)
22+
throw new ArgumentException($"{nameof(IJsonApiContext)}.{nameof(_jsonApiContext.RequestEntity)} cannot be null. "
23+
+ "This property contains the ResourceGraph node for the requested entity. "
24+
+ "If this is a unit test, you need to mock this member. "
25+
+ "See this issue to check the current status of improved test guidelines: "
26+
+ "https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/251", nameof(jsonApiContext));
27+
28+
if(_jsonApiContext.ResourceGraph == null)
29+
throw new ArgumentException($"{nameof(IJsonApiContext)}.{nameof(_jsonApiContext.ResourceGraph)} cannot be null. "
30+
+ "If this is a unit test, you need to construct a graph containing the resources being tested. "
31+
+ "See this issue to check the current status of improved test guidelines: "
32+
+ "https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/251", nameof(jsonApiContext));
33+
1934
if (baseQuery.IsAttributeOfRelationship)
2035
{
2136
Relationship = GetRelationship(baseQuery.Relationship);

src/JsonApiDotNetCore/Internal/Query/SortQuery.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ namespace JsonApiDotNetCore.Internal.Query
88
/// </summary>
99
public class SortQuery : BaseQuery
1010
{
11-
[Obsolete("Use constructor overload (SortDirection, string) instead.", error: true)]
11+
[Obsolete("Use constructor overload (SortDirection, string) instead. The string should be the publicly exposed attribute name.", error: true)]
1212
public SortQuery(SortDirection direction, AttrAttribute sortedAttribute)
1313
: base(sortedAttribute.PublicAttributeName) { }
1414

src/JsonApiDotNetCore/Services/EntityResourceService.cs

Lines changed: 6 additions & 1 deletion
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
}

src/JsonApiDotNetCore/Services/QueryParser.cs

Lines changed: 20 additions & 11 deletions
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;

test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System;
12
using System.Linq;
23
using System.Net;
34
using System.Net.Http;
@@ -358,5 +359,56 @@ public async Task Request_ToIncludeRelationshipMarkedCanIncludeFalse_Returns_400
358359
// assert
359360
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
360361
}
362+
363+
[Fact]
364+
public async Task Can_Ignore_Null_Parent_In_Nested_Include()
365+
{
366+
// arrange
367+
var todoItem = _todoItemFaker.Generate();
368+
todoItem.Owner = _personFaker.Generate();
369+
todoItem.CreatedDate = DateTime.Now;
370+
_context.TodoItems.Add(todoItem);
371+
_context.SaveChanges();
372+
373+
var todoItemWithNullOwner = _todoItemFaker.Generate();
374+
todoItemWithNullOwner.Owner = null;
375+
todoItemWithNullOwner.CreatedDate = DateTime.Now;
376+
_context.TodoItems.Add(todoItemWithNullOwner);
377+
_context.SaveChanges();
378+
379+
var builder = new WebHostBuilder()
380+
.UseStartup<Startup>();
381+
382+
var httpMethod = new HttpMethod("GET");
383+
384+
var route = $"/api/v1/todo-items?sort=-created-date&page[size]=2&include=owner.role"; // last two todo-items
385+
386+
var server = new TestServer(builder);
387+
var client = server.CreateClient();
388+
var request = new HttpRequestMessage(httpMethod, route);
389+
390+
// act
391+
var response = await client.SendAsync(request);
392+
var responseString = await response.Content.ReadAsStringAsync();
393+
var documents = JsonConvert.DeserializeObject<Documents>(responseString);
394+
395+
// assert
396+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
397+
Assert.Single(documents.Included);
398+
399+
var ownerValueNull = documents.Data
400+
.First(i => i.Id == todoItemWithNullOwner.StringId)
401+
.Relationships.First(i => i.Key == "owner")
402+
.Value.SingleData;
403+
404+
Assert.Null(ownerValueNull);
405+
406+
var ownerValue = documents.Data
407+
.First(i => i.Id == todoItem.StringId)
408+
.Relationships.First(i => i.Key == "owner")
409+
.Value.SingleData;
410+
411+
Assert.NotNull(ownerValue);
412+
}
361413
}
362414
}

0 commit comments

Comments
 (0)