Skip to content

Commit 9d76d05

Browse files
author
Bart Koelman
committed
Fixed: relationships were not returned when using sparse fieldsets
Fixed: filter out duplicate selections Fixed: Top-level fields were missing when selecting only related fields Fixed: Select all fields when sparse fieldset contains a read-only property
1 parent 8b6a94e commit 9d76d05

File tree

10 files changed

+412
-118
lines changed

10 files changed

+412
-118
lines changed

src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,11 @@ public virtual IQueryable<TResource> Get(TId id)
6262
}
6363

6464
/// <inheritdoc />
65-
public virtual IQueryable<TResource> Select(IQueryable<TResource> entities, IEnumerable<AttrAttribute> fields = null)
65+
public virtual IQueryable<TResource> Select(IQueryable<TResource> entities, IEnumerable<string> propertyNames = null)
6666
{
67-
_logger.LogTrace($"Entering {nameof(Select)}({nameof(entities)}, {nameof(fields)}).");
67+
_logger.LogTrace($"Entering {nameof(Select)}({nameof(entities)}, {nameof(propertyNames)}).");
6868

69-
if (fields != null && fields.Any())
70-
return entities.Select(fields);
71-
72-
return entities;
69+
return entities.Select(propertyNames);
7370
}
7471

7572
/// <inheritdoc />

src/JsonApiDotNetCore/Data/IResourceReadRepository.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public interface IResourceReadRepository<TResource, in TId>
2626
/// <summary>
2727
/// Apply fields to the provided queryable
2828
/// </summary>
29-
IQueryable<TResource> Select(IQueryable<TResource> entities, IEnumerable<AttrAttribute> fields);
29+
IQueryable<TResource> Select(IQueryable<TResource> entities, IEnumerable<string> propertyNames);
3030
/// <summary>
3131
/// Include a relationship in the query
3232
/// </summary>

src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,10 @@ public static IQueryable<TSource> Filter<TSource>(this IQueryable<TSource> sourc
6464
return CallGenericWhereMethod(source, filterQuery);
6565
}
6666

67-
public static IQueryable<TSource> Select<TSource>(this IQueryable<TSource> source, IEnumerable<AttrAttribute> columns)
68-
=> CallGenericSelectMethod(source, columns.Select(attr => attr.PropertyInfo.Name).ToList());
67+
public static IQueryable<TSource> Select<TSource>(this IQueryable<TSource> source, IEnumerable<string> columns)
68+
{
69+
return columns == null || !columns.Any() ? source : CallGenericSelectMethod(source, columns);
70+
}
6971

7072
public static IOrderedQueryable<TSource> Sort<TSource>(this IQueryable<TSource> source, SortQueryContext sortQuery)
7173
{
@@ -339,26 +341,28 @@ private static Expression CreateTupleAccessForConstantExpression(object value, T
339341
return Expression.Property(tupleCreateCall, "Item1");
340342
}
341343

342-
private static IQueryable<TSource> CallGenericSelectMethod<TSource>(IQueryable<TSource> source, List<string> columns)
344+
private static IQueryable<TSource> CallGenericSelectMethod<TSource>(IQueryable<TSource> source, IEnumerable<string> columns)
343345
{
344346
var sourceType = typeof(TSource);
345347
var parameter = Expression.Parameter(source.ElementType, "x");
346-
var sourceProperties = new List<string>();
348+
var sourceProperties = new HashSet<string>();
347349

348350
// Store all property names to it's own related property (name as key)
349-
var nestedTypesAndProperties = new Dictionary<string, List<string>>();
351+
var nestedTypesAndProperties = new Dictionary<string, HashSet<string>>();
350352
foreach (var column in columns)
351353
{
352354
var props = column.Split('.');
353355
if (props.Length > 1) // Nested property
354356
{
355357
if (nestedTypesAndProperties.TryGetValue(props[0], out var properties) == false)
356-
nestedTypesAndProperties.Add(props[0], new List<string> { nameof(Identifiable.Id), props[1] });
358+
nestedTypesAndProperties.Add(props[0], new HashSet<string> { nameof(Identifiable.Id), props[1] });
357359
else
358360
properties.Add(props[1]);
359361
}
360362
else
363+
{
361364
sourceProperties.Add(props[0]);
365+
}
362366
}
363367

364368
// Bind attributes on TSource

src/JsonApiDotNetCore/QueryParameterServices/Contracts/ISparseFieldsService.cs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System.Collections.Generic;
1+
using System.Collections.Generic;
22
using JsonApiDotNetCore.Models;
33

44
namespace JsonApiDotNetCore.Query
@@ -12,7 +12,11 @@ public interface ISparseFieldsService : IQueryParameterService
1212
/// Gets the list of targeted fields. If a relationship is supplied,
1313
/// gets the list of targeted fields for that relationship.
1414
/// </summary>
15-
/// <param name="relationship"></param>
1615
List<AttrAttribute> Get(RelationshipAttribute relationship = null);
16+
17+
/// <summary>
18+
/// Gets the set of all targeted fields, including fields for related entities, as a set of dotted property names.
19+
/// </summary>
20+
ISet<string> GetAll();
1721
}
18-
}
22+
}

src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs

Lines changed: 72 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,16 @@ public class SparseFieldsService : QueryParameterService, ISparseFieldsService
1717
/// <summary>
1818
/// The selected fields for the primary resource of this request.
1919
/// </summary>
20-
private List<AttrAttribute> _selectedFields;
20+
private readonly List<AttrAttribute> _selectedFields = new List<AttrAttribute>();
21+
2122
/// <summary>
2223
/// The selected field for any included relationships
2324
/// </summary>
24-
private readonly Dictionary<RelationshipAttribute, List<AttrAttribute>> _selectedRelationshipFields;
25+
private readonly Dictionary<RelationshipAttribute, List<AttrAttribute>> _selectedRelationshipFields = new Dictionary<RelationshipAttribute, List<AttrAttribute>>();
2526

26-
public SparseFieldsService(IResourceGraph resourceGraph, ICurrentRequest currentRequest) : base(resourceGraph, currentRequest)
27+
public SparseFieldsService(IResourceGraph resourceGraph, ICurrentRequest currentRequest)
28+
: base(resourceGraph, currentRequest)
2729
{
28-
_selectedFields = new List<AttrAttribute>();
29-
_selectedRelationshipFields = new Dictionary<RelationshipAttribute, List<AttrAttribute>>();
3030
}
3131

3232
/// <inheritdoc/>
@@ -35,8 +35,23 @@ public List<AttrAttribute> Get(RelationshipAttribute relationship = null)
3535
if (relationship == null)
3636
return _selectedFields;
3737

38-
_selectedRelationshipFields.TryGetValue(relationship, out var fields);
39-
return fields;
38+
return _selectedRelationshipFields.TryGetValue(relationship, out var fields)
39+
? fields
40+
: new List<AttrAttribute>();
41+
}
42+
43+
public ISet<string> GetAll()
44+
{
45+
var properties = new HashSet<string>();
46+
properties.AddRange(_selectedFields.Select(x => x.PropertyInfo.Name));
47+
48+
foreach (var pair in _selectedRelationshipFields)
49+
{
50+
string pathPrefix = pair.Key.RelationshipPath + ".";
51+
properties.AddRange(pair.Value.Select(x => pathPrefix + x.PropertyInfo.Name));
52+
}
53+
54+
return properties;
4055
}
4156

4257
/// <inheritdoc/>
@@ -67,9 +82,9 @@ public virtual void Parse(string parameterName, StringValues parameterValue)
6782
var keySplit = parameterName.Split(QueryConstants.OPEN_BRACKET, QueryConstants.CLOSE_BRACKET);
6883

6984
if (keySplit.Length == 1)
70-
{ // input format: fields=prop1,prop2
71-
foreach (var field in fields)
72-
RegisterRequestResourceField(field, parameterName);
85+
{
86+
// input format: fields=prop1,prop2
87+
RegisterRequestResourceFields(fields, parameterName);
7388
}
7489
else
7590
{ // input format: fields[articles]=prop1,prop2
@@ -98,45 +113,73 @@ public virtual void Parse(string parameterName, StringValues parameterValue)
98113
$"'{navigation}' in 'fields[{navigation}]' is not a valid relationship of {_requestResource.ResourceName}.");
99114
}
100115

101-
foreach (var field in fields)
102-
RegisterRelatedResourceField(field, relationship, parameterName);
116+
RegisterRelatedResourceFields(fields, relationship, parameterName);
103117
}
104118
}
105119

106120
/// <summary>
107-
/// Registers field selection queries of the form articles?fields[author]=firstName
121+
/// Registers field selection of the form articles?fields[author]=firstName,lastName
108122
/// </summary>
109-
private void RegisterRelatedResourceField(string field, RelationshipAttribute relationship, string parameterName)
123+
private void RegisterRelatedResourceFields(IEnumerable<string> fields, RelationshipAttribute relationship, string parameterName)
110124
{
111-
var relationProperty = _resourceGraph.GetResourceContext(relationship.RightType);
112-
var attr = relationProperty.Attributes.SingleOrDefault(a => a.Is(field));
113-
if (attr == null)
125+
var selectedFields = new List<AttrAttribute>();
126+
127+
foreach (var field in fields)
114128
{
115-
// TODO: Add unit test for this error, once the nesting limitation is removed and this code becomes reachable again.
129+
var relationProperty = _resourceGraph.GetResourceContext(relationship.RightType);
130+
var attr = relationProperty.Attributes.SingleOrDefault(a => a.Is(field));
131+
if (attr == null)
132+
{
133+
// TODO: Add unit test for this error, once the nesting limitation is removed and this code becomes reachable again.
134+
135+
throw new InvalidQueryStringParameterException(parameterName,
136+
"Sparse field navigation path refers to an invalid related field.",
137+
$"Related resource '{relationship.RightType.Name}' does not contain an attribute named '{field}'.");
138+
}
116139

117-
throw new InvalidQueryStringParameterException(parameterName, "Sparse field navigation path refers to an invalid related field.",
118-
$"Related resource '{relationship.RightType.Name}' does not contain an attribute named '{field}'.");
140+
if (attr.PropertyInfo.SetMethod == null)
141+
{
142+
// A read-only property was selected. Its value likely depends on another property, so include all related fields.
143+
return;
144+
}
145+
146+
selectedFields.Add(attr);
119147
}
120148

121149
if (!_selectedRelationshipFields.TryGetValue(relationship, out var registeredFields))
150+
{
122151
_selectedRelationshipFields.Add(relationship, registeredFields = new List<AttrAttribute>());
123-
registeredFields.Add(attr);
152+
}
153+
registeredFields.AddRange(selectedFields);
124154
}
125155

126156
/// <summary>
127-
/// Registers field selection queries of the form articles?fields=title
157+
/// Registers field selection of the form articles?fields=title,description
128158
/// </summary>
129-
private void RegisterRequestResourceField(string field, string parameterName)
159+
private void RegisterRequestResourceFields(IEnumerable<string> fields, string parameterName)
130160
{
131-
var attr = _requestResource.Attributes.SingleOrDefault(a => a.Is(field));
132-
if (attr == null)
161+
var selectedFields = new List<AttrAttribute>();
162+
163+
foreach (var field in fields)
133164
{
134-
throw new InvalidQueryStringParameterException(parameterName,
135-
"The specified field does not exist on the requested resource.",
136-
$"The field '{field}' does not exist on resource '{_requestResource.ResourceName}'.");
165+
var attr = _requestResource.Attributes.SingleOrDefault(a => a.Is(field));
166+
if (attr == null)
167+
{
168+
throw new InvalidQueryStringParameterException(parameterName,
169+
"The specified field does not exist on the requested resource.",
170+
$"The field '{field}' does not exist on resource '{_requestResource.ResourceName}'.");
171+
}
172+
173+
if (attr.PropertyInfo.SetMethod == null)
174+
{
175+
// A read-only property was selected. Its value likely depends on another property, so include all resource fields.
176+
return;
177+
}
178+
179+
selectedFields.Add(attr);
137180
}
138181

139-
(_selectedFields ??= new List<AttrAttribute>()).Add(attr);
182+
_selectedFields.AddRange(selectedFields);
140183
}
141184
}
142185
}

src/JsonApiDotNetCore/Serialization/Server/FieldsToSerialize.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ public List<AttrAttribute> GetAllowedAttributes(Type type, RelationshipAttribute
3434
allowed = allowed.Intersect(resourceDefinition.GetAllowedAttributes()).ToList();
3535

3636
var sparseFieldsSelection = _sparseFieldsService.Get(relationship);
37-
if (sparseFieldsSelection != null && sparseFieldsSelection.Any())
37+
if (sparseFieldsSelection.Any())
3838
// from the allowed attributes, select the ones flagged by sparse field selection.
3939
allowed = allowed.Intersect(sparseFieldsSelection).ToList();
4040

src/JsonApiDotNetCore/Services/DefaultResourceService.cs

Lines changed: 56 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ public virtual async Task<TResource> GetRelationshipsAsync(TId id, string relati
159159
// BeforeRead hook execution
160160
_hookExecutor?.BeforeRead<TResource>(ResourcePipeline.GetRelationship, id.ToString());
161161

162-
var entityQuery = ApplyInclude(_repository.Get(id), chainPrefix: new List<RelationshipAttribute> { relationship });
162+
var entityQuery = ApplyInclude(_repository.Get(id), relationship);
163163
var entity = await _repository.FirstOrDefaultAsync(entityQuery);
164164

165165
if (entity == null)
@@ -299,78 +299,92 @@ protected virtual IQueryable<TResource> ApplyFilter(IQueryable<TResource> entiti
299299
return entities;
300300
}
301301

302-
303302
/// <summary>
304303
/// Applies include queries
305304
/// </summary>
306-
protected virtual IQueryable<TResource> ApplyInclude(IQueryable<TResource> entities, IEnumerable<RelationshipAttribute> chainPrefix = null)
305+
protected virtual IQueryable<TResource> ApplyInclude(IQueryable<TResource> entities, RelationshipAttribute chainPrefix = null)
307306
{
308307
var chains = _includeService.Get();
309-
bool hasInclusionChain = chains.Any();
310308

311-
if (chains == null)
309+
if (chainPrefix != null)
312310
{
313-
throw new Exception();
311+
chains.Add(new List<RelationshipAttribute>());
314312
}
315313

316-
if (chainPrefix != null && !hasInclusionChain)
317-
{
318-
hasInclusionChain = true;
319-
chains.Add(new List<RelationshipAttribute>());
320-
}
321-
322-
323-
if (hasInclusionChain)
314+
foreach (var inclusionChain in chains)
324315
{
325-
foreach (var inclusionChain in chains)
316+
if (chainPrefix != null)
326317
{
327-
if (chainPrefix != null)
328-
{
329-
inclusionChain.InsertRange(0, chainPrefix);
330-
}
331-
entities = _repository.Include(entities, inclusionChain.ToArray());
318+
inclusionChain.Insert(0, chainPrefix);
332319
}
320+
321+
entities = _repository.Include(entities, inclusionChain);
333322
}
334323

335324
return entities;
336325
}
337326

338327
/// <summary>
339-
/// Applies sparse field selection queries
328+
/// Applies sparse field selection to queries
340329
/// </summary>
341-
/// <param name="entities"></param>
342-
/// <returns></returns>
343330
protected virtual IQueryable<TResource> ApplySelect(IQueryable<TResource> entities)
344331
{
345-
var fields = _sparseFieldsService.Get();
346-
if (fields != null && fields.Any())
347-
entities = _repository.Select(entities, fields.ToArray());
332+
var propertyNames = _sparseFieldsService.GetAll();
333+
334+
if (propertyNames.Any())
335+
{
336+
// All resources without a sparse fieldset specified must be entirely selected.
337+
EnsureResourcesWithoutSparseFieldSetAreAddedToSelect(propertyNames);
338+
}
348339

340+
entities = _repository.Select(entities, propertyNames);
349341
return entities;
350342
}
351343

344+
private void EnsureResourcesWithoutSparseFieldSetAreAddedToSelect(ISet<string> propertyNames)
345+
{
346+
bool hasTopLevelSparseFieldSet = propertyNames.Any(x => !x.Contains("."));
347+
if (!hasTopLevelSparseFieldSet)
348+
{
349+
var topPropertyNames = _currentRequestResource.Attributes
350+
.Where(x => x.PropertyInfo.SetMethod != null)
351+
.Select(x => x.PropertyInfo.Name);
352+
propertyNames.AddRange(topPropertyNames);
353+
}
354+
355+
var chains = _includeService.Get();
356+
foreach (var inclusionChain in chains)
357+
{
358+
string relationshipPath = null;
359+
foreach (var relationship in inclusionChain)
360+
{
361+
relationshipPath = relationshipPath == null
362+
? relationship.RelationshipPath
363+
: $"{relationshipPath}.{relationship.RelationshipPath}";
364+
}
365+
366+
if (relationshipPath != null)
367+
{
368+
bool hasRelationSparseFieldSet = propertyNames.Any(x => x.StartsWith(relationshipPath + "."));
369+
if (!hasRelationSparseFieldSet)
370+
{
371+
propertyNames.Add(relationshipPath);
372+
}
373+
}
374+
}
375+
}
376+
352377
/// <summary>
353378
/// Get the specified id with relationships provided in the post request
354379
/// </summary>
355-
/// <param name="id"></param>i
356-
/// <returns></returns>
357380
private async Task<TResource> GetWithRelationshipsAsync(TId id)
358381
{
359-
var sparseFieldset = _sparseFieldsService.Get();
360-
var query = _repository.Select(_repository.Get(id), sparseFieldset.ToArray());
382+
var query = _repository.Get(id);
383+
query = ApplyInclude(query);
384+
query = ApplySelect(query);
361385

362-
foreach (var chain in _includeService.Get())
363-
query = _repository.Include(query, chain.ToArray());
364-
365-
TResource value;
366-
// https://github.com/aspnet/EntityFrameworkCore/issues/6573
367-
if (sparseFieldset.Any())
368-
value = query.FirstOrDefault();
369-
else
370-
value = await _repository.FirstOrDefaultAsync(query);
371-
372-
373-
return value;
386+
var entity = await _repository.FirstOrDefaultAsync(query);
387+
return entity;
374388
}
375389

376390
private bool IsNull(params object[] values)

0 commit comments

Comments
 (0)