Skip to content

Commit bd4dda1

Browse files
author
Bart Koelman
committed
Flexible collection types/interfaces on HasManyThrough attributes
1 parent 74aa7df commit bd4dda1

File tree

8 files changed

+80
-67
lines changed

8 files changed

+80
-67
lines changed

src/Examples/JsonApiDotNetCoreExample/Models/Article.cs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,12 @@ public sealed class Article : Identifiable
1515

1616
[NotMapped]
1717
[HasManyThrough(nameof(ArticleTags))]
18-
public List<Tag> Tags { get; set; }
19-
public List<ArticleTag> ArticleTags { get; set; }
20-
18+
public ISet<Tag> Tags { get; set; }
19+
public ISet<ArticleTag> ArticleTags { get; set; }
2120

2221
[NotMapped]
2322
[HasManyThrough(nameof(IdentifiableArticleTags))]
24-
public List<Tag> IdentifiableTags { get; set; }
25-
public List<IdentifiableArticleTag> IdentifiableArticleTags { get; set; }
23+
public ISet<Tag> IdentifiableTags { get; set; }
24+
public ISet<IdentifiableArticleTag> IdentifiableArticleTags { get; set; }
2625
}
2726
}

src/JsonApiDotNetCore/Builders/ResourceGraphBuilder.cs

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using System;
2-
using System.Collections;
32
using System.Collections.Generic;
43
using System.Linq;
54
using System.Reflection;
@@ -136,22 +135,19 @@ protected virtual List<RelationshipAttribute> GetRelationships(Type entityType)
136135

137136
if (attribute is HasManyThroughAttribute hasManyThroughAttribute)
138137
{
139-
var throughProperty = properties.SingleOrDefault(p => p.Name == hasManyThroughAttribute.InternalThroughName);
138+
var throughProperty = properties.SingleOrDefault(p => p.Name == hasManyThroughAttribute.ThroughPropertyName);
140139
if (throughProperty == null)
141-
throw new JsonApiSetupException($"Invalid '{nameof(HasManyThroughAttribute)}' on type '{entityType}'. Type does not contain a property named '{hasManyThroughAttribute.InternalThroughName}'.");
140+
throw new JsonApiSetupException($"Invalid {nameof(HasManyThroughAttribute)} on '{entityType}.{attribute.PropertyInfo.Name}': Resource does not contain a property named '{hasManyThroughAttribute.ThroughPropertyName}'.");
142141

143-
if (throughProperty.PropertyType.Implements<IList>() == false)
144-
throw new JsonApiSetupException($"Invalid '{nameof(HasManyThroughAttribute)}' on type '{entityType}.{throughProperty.Name}'. Property type does not implement IList.");
142+
var throughType = TryGetManyThroughType(throughProperty);
143+
if (throughType == null)
144+
throw new JsonApiSetupException($"Invalid {nameof(HasManyThroughAttribute)} on '{entityType}.{attribute.PropertyInfo.Name}': Referenced property '{throughProperty.Name}' does not implement 'ICollection<T>'.");
145145

146-
// assumption: the property should be a generic collection, e.g. List<ArticleTag>
147-
if (throughProperty.PropertyType.IsGenericType == false)
148-
throw new JsonApiSetupException($"Invalid '{nameof(HasManyThroughAttribute)}' on type '{entityType}'. Expected through entity to be a generic type, such as List<{prop.PropertyType}>.");
149-
150-
// Article → List<ArticleTag>
146+
// ICollection<ArticleTag>
151147
hasManyThroughAttribute.ThroughProperty = throughProperty;
152148

153149
// ArticleTag
154-
hasManyThroughAttribute.ThroughType = throughProperty.PropertyType.GetGenericArguments()[0];
150+
hasManyThroughAttribute.ThroughType = throughType;
155151

156152
var throughProperties = hasManyThroughAttribute.ThroughType.GetProperties();
157153

@@ -164,7 +160,7 @@ protected virtual List<RelationshipAttribute> GetRelationships(Type entityType)
164160
hasManyThroughAttribute.LeftIdProperty = throughProperties.SingleOrDefault(x => x.Name == leftIdPropertyName)
165161
?? throw new JsonApiSetupException($"{hasManyThroughAttribute.ThroughType} does not contain a relationship id property to type {entityType} with name {leftIdPropertyName}");
166162

167-
// Article → ArticleTag.Tag
163+
// ArticleTag.Tag
168164
hasManyThroughAttribute.RightProperty = throughProperties.SingleOrDefault(x => x.PropertyType == hasManyThroughAttribute.RightType)
169165
?? throw new JsonApiSetupException($"{hasManyThroughAttribute.ThroughType} does not contain a navigation property to type {hasManyThroughAttribute.RightType}");
170166

@@ -178,6 +174,24 @@ protected virtual List<RelationshipAttribute> GetRelationships(Type entityType)
178174
return attributes;
179175
}
180176

177+
private static Type TryGetManyThroughType(PropertyInfo throughProperty)
178+
{
179+
if (throughProperty.PropertyType.IsGenericType)
180+
{
181+
var typeArguments = throughProperty.PropertyType.GetGenericArguments();
182+
if (typeArguments.Length == 1)
183+
{
184+
var constructedThroughType = typeof(ICollection<>).MakeGenericType(typeArguments[0]);
185+
if (throughProperty.PropertyType.Implements(constructedThroughType))
186+
{
187+
return typeArguments[0];
188+
}
189+
}
190+
}
191+
192+
return null;
193+
}
194+
181195
protected virtual Type GetRelationshipType(RelationshipAttribute relation, PropertyInfo prop) =>
182196
relation is HasOneAttribute ? prop.PropertyType : prop.PropertyType.GetGenericArguments()[0];
183197

src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,7 @@ protected void LoadCurrentRelationships(TResource oldEntity, RelationshipAttribu
423423
{
424424
if (relationshipAttribute is HasManyThroughAttribute throughAttribute)
425425
{
426-
_context.Entry(oldEntity).Collection(throughAttribute.InternalThroughName).Load();
426+
_context.Entry(oldEntity).Collection(throughAttribute.ThroughProperty.Name).Load();
427427
}
428428
else if (relationshipAttribute is HasManyAttribute hasManyAttribute)
429429
{

src/JsonApiDotNetCore/Extensions/TypeExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ public static bool Implements<T>(this Type concreteType)
108108
/// <summary>
109109
/// Whether or not a type implements an interface.
110110
/// </summary>
111-
private static bool Implements(this Type concreteType, Type interfaceType)
111+
public static bool Implements(this Type concreteType, Type interfaceType)
112112
=> interfaceType?.IsAssignableFrom(concreteType) == true;
113113

114114
/// <summary>

src/JsonApiDotNetCore/Hooks/Traversal/RelationshipProxy.cs

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ namespace JsonApiDotNetCore.Hooks
1919
/// </summary>
2020
internal sealed class RelationshipProxy
2121
{
22-
private readonly bool _isHasManyThrough;
2322
private readonly bool _skipJoinTable;
2423

2524
/// <summary>
@@ -40,7 +39,6 @@ public RelationshipProxy(RelationshipAttribute attr, Type relatedType, bool isCo
4039
IsContextRelation = isContextRelation;
4140
if (attr is HasManyThroughAttribute throughAttr)
4241
{
43-
_isHasManyThrough = true;
4442
_skipJoinTable |= RightType != throughAttr.ThroughType;
4543
}
4644
}
@@ -54,23 +52,23 @@ public RelationshipProxy(RelationshipAttribute attr, Type relatedType, bool isCo
5452
/// <param name="entity">Parent entity.</param>
5553
public object GetValue(IIdentifiable entity)
5654
{
57-
if (_isHasManyThrough)
55+
if (Attribute is HasManyThroughAttribute hasManyThrough)
5856
{
59-
var throughAttr = (HasManyThroughAttribute)Attribute;
6057
if (!_skipJoinTable)
6158
{
62-
return throughAttr.ThroughProperty.GetValue(entity);
59+
return hasManyThrough.ThroughProperty.GetValue(entity);
6360
}
6461
var collection = new List<IIdentifiable>();
65-
var joinEntities = (IEnumerable)throughAttr.ThroughProperty.GetValue(entity);
62+
var joinEntities = (IEnumerable)hasManyThrough.ThroughProperty.GetValue(entity);
6663
if (joinEntities == null) return null;
6764

6865
foreach (var joinEntity in joinEntities)
6966
{
70-
var rightEntity = (IIdentifiable)throughAttr.RightProperty.GetValue(joinEntity);
67+
var rightEntity = (IIdentifiable)hasManyThrough.RightProperty.GetValue(joinEntity);
7168
if (rightEntity == null) continue;
7269
collection.Add(rightEntity);
7370
}
71+
7472
return collection;
7573
}
7674
return Attribute.GetValue(entity);
@@ -85,30 +83,31 @@ public object GetValue(IIdentifiable entity)
8583
/// <param name="value">The relationship value.</param>
8684
public void SetValue(IIdentifiable entity, object value)
8785
{
88-
if (_isHasManyThrough)
86+
if (Attribute is HasManyThroughAttribute hasManyThrough)
8987
{
9088
if (!_skipJoinTable)
9189
{
92-
var list = (IEnumerable<object>)value;
93-
((HasManyThroughAttribute)Attribute).ThroughProperty.SetValue(entity, list.CopyToList(RightType));
90+
hasManyThrough.ThroughProperty.SetValue(entity, value);
9491
return;
9592
}
96-
var throughAttr = (HasManyThroughAttribute)Attribute;
97-
var joinEntities = (IEnumerable<object>)throughAttr.ThroughProperty.GetValue(entity);
93+
94+
var joinEntities = (IEnumerable)hasManyThrough.ThroughProperty.GetValue(entity);
9895

9996
var filteredList = new List<object>();
100-
var rightEntities = ((IEnumerable<object>)value).CopyToList(RightType);
101-
foreach (var je in joinEntities)
97+
var rightEntities = ((IEnumerable)value).CopyToList(RightType);
98+
foreach (var joinEntity in joinEntities)
10299
{
103-
104-
if (((IList)rightEntities).Contains(throughAttr.RightProperty.GetValue(je)))
100+
if (((IList)rightEntities).Contains(hasManyThrough.RightProperty.GetValue(joinEntity)))
105101
{
106-
filteredList.Add(je);
102+
filteredList.Add(joinEntity);
107103
}
108104
}
109-
throughAttr.ThroughProperty.SetValue(entity, filteredList.CopyToList(throughAttr.ThroughType));
105+
106+
var collectionValue = filteredList.CopyToTypedCollection(hasManyThrough.ThroughProperty.PropertyType);
107+
hasManyThrough.ThroughProperty.SetValue(entity, collectionValue);
110108
return;
111109
}
110+
112111
Attribute.SetValue(entity, value);
113112
}
114113
}

src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ namespace JsonApiDotNetCore.Models
1111
{
1212
/// <summary>
1313
/// Create a HasMany relationship through a many-to-many join relationship.
14-
/// This type can only be applied on types that implement IList.
14+
/// This type can only be applied on types that implement ICollection.
1515
/// </summary>
1616
///
1717
/// <example>
@@ -22,8 +22,8 @@ namespace JsonApiDotNetCore.Models
2222
/// <code>
2323
/// [NotMapped]
2424
/// [HasManyThrough("tags", nameof(ArticleTags))]
25-
/// public List&lt;Tag&gt; Tags { get; set; }
26-
/// public List&lt;ArticleTag&gt; ArticleTags { get; set; }
25+
/// public ICollection&lt;Tag&gt; Tags { get; set; }
26+
/// public ICollection&lt;ArticleTag&gt; ArticleTags { get; set; }
2727
/// </code>
2828
/// </example>
2929
[AttributeUsage(AttributeTargets.Property)]
@@ -34,39 +34,39 @@ public sealed class HasManyThroughAttribute : HasManyAttribute
3434
/// The public name exposed through the API will be based on the configured convention.
3535
/// </summary>
3636
///
37-
/// <param name="internalThroughName">The name of the navigation property that will be used to get the HasMany relationship</param>
37+
/// <param name="throughPropertyName">The name of the navigation property that will be used to get the HasMany relationship</param>
3838
/// <param name="relationshipLinks">Which links are available. Defaults to <see cref="Link.All"/></param>
3939
/// <param name="canInclude">Whether or not this relationship can be included using the <c>?include=public-name</c> query string</param>
4040
///
4141
/// <example>
4242
/// <code>
43-
/// [HasManyThrough(nameof(ArticleTags), documentLinks: Link.All, canInclude: true)]
43+
/// [HasManyThrough(nameof(ArticleTags), relationshipLinks: Link.All, canInclude: true)]
4444
/// </code>
4545
/// </example>
46-
public HasManyThroughAttribute(string internalThroughName, Link relationshipLinks = Link.All, bool canInclude = true)
46+
public HasManyThroughAttribute(string throughPropertyName, Link relationshipLinks = Link.All, bool canInclude = true)
4747
: base(null, relationshipLinks, canInclude)
4848
{
49-
InternalThroughName = internalThroughName;
49+
ThroughPropertyName = throughPropertyName;
5050
}
5151

5252
/// <summary>
5353
/// Create a HasMany relationship through a many-to-many join relationship.
5454
/// </summary>
5555
///
5656
/// <param name="publicName">The relationship name as exposed by the API</param>
57-
/// <param name="internalThroughName">The name of the navigation property that will be used to get the HasMany relationship</param>
58-
/// <param name="documentLinks">Which links are available. Defaults to <see cref="Link.All"/></param>
57+
/// <param name="throughPropertyName">The name of the navigation property that will be used to get the HasMany relationship</param>
58+
/// <param name="relationshipLinks">Which links are available. Defaults to <see cref="Link.All"/></param>
5959
/// <param name="canInclude">Whether or not this relationship can be included using the <c>?include=public-name</c> query string</param>
6060
///
6161
/// <example>
6262
/// <code>
63-
/// [HasManyThrough("tags", nameof(ArticleTags), documentLinks: Link.All, canInclude: true)]
63+
/// [HasManyThrough("tags", nameof(ArticleTags), relationshipLinks: Link.All, canInclude: true)]
6464
/// </code>
6565
/// </example>
66-
public HasManyThroughAttribute(string publicName, string internalThroughName, Link documentLinks = Link.All, bool canInclude = true)
67-
: base(publicName, documentLinks, canInclude)
66+
public HasManyThroughAttribute(string publicName, string throughPropertyName, Link relationshipLinks = Link.All, bool canInclude = true)
67+
: base(publicName, relationshipLinks, canInclude)
6868
{
69-
InternalThroughName = internalThroughName;
69+
ThroughPropertyName = throughPropertyName;
7070
}
7171

7272
/// <summary>
@@ -78,7 +78,7 @@ public override object GetValue(object entity)
7878
{
7979
var throughNavigationProperty = entity.GetType()
8080
.GetProperties()
81-
.Single(p => p.Name == InternalThroughName);
81+
.Single(p => p.Name == ThroughProperty.Name);
8282

8383
var throughEntities = throughNavigationProperty.GetValue(entity);
8484

@@ -105,16 +105,17 @@ public override void SetValue(object entity, object newValue)
105105
}
106106
else
107107
{
108-
var throughRelationshipCollection = ThroughProperty.PropertyType.New<IList>();
109-
ThroughProperty.SetValue(entity, throughRelationshipCollection);
110-
111-
foreach (IIdentifiable pointer in (IList)newValue)
108+
List<object> instances = new List<object>();
109+
foreach (IIdentifiable resource in (IEnumerable)newValue)
112110
{
113-
var throughInstance = ThroughType.New();
111+
object throughInstance = ThroughType.New();
114112
LeftProperty.SetValue(throughInstance, entity);
115-
RightProperty.SetValue(throughInstance, pointer);
116-
throughRelationshipCollection.Add(throughInstance);
113+
RightProperty.SetValue(throughInstance, resource);
114+
instances.Add(throughInstance);
117115
}
116+
117+
var typedCollection = instances.CopyToTypedCollection(ThroughProperty.PropertyType);
118+
ThroughProperty.SetValue(entity, typedCollection);
118119
}
119120
}
120121

@@ -125,7 +126,7 @@ public override void SetValue(object entity, object newValue)
125126
/// In the `[HasManyThrough("tags", nameof(ArticleTags))]` example
126127
/// this would be "ArticleTags".
127128
/// </example>
128-
public string InternalThroughName { get; }
129+
internal string ThroughPropertyName { get; }
129130

130131
/// <summary>
131132
/// The join type.
@@ -205,7 +206,7 @@ public override void SetValue(object entity, object newValue)
205206
/// this would point to the `Article.ArticleTags` property
206207
///
207208
/// <code>
208-
/// public List&lt;ArticleTags&gt; ArticleTags { get; set; }
209+
/// public ICollection&lt;ArticleTags&gt; ArticleTags { get; set; }
209210
/// </code>
210211
///
211212
/// </example>
@@ -215,6 +216,6 @@ public override void SetValue(object entity, object newValue)
215216
/// <example>
216217
/// "ArticleTags.Tag"
217218
/// </example>
218-
public override string RelationshipPath => $"{InternalThroughName}.{RightProperty.Name}";
219+
public override string RelationshipPath => $"{ThroughProperty.Name}.{RightProperty.Name}";
219220
}
220221
}

test/UnitTests/ResourceHooks/ResourceHookExecutor/ManyToMany_OnReturnTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ public sealed class ManyToMany_OnReturnTests : HooksTestsSetup
1616
var tagsSubset = _tagFaker.Generate(3);
1717
var joinsSubSet = _articleTagFaker.Generate(3);
1818
var articleTagsSubset = _articleFaker.Generate();
19-
articleTagsSubset.ArticleTags = joinsSubSet;
19+
articleTagsSubset.ArticleTags = joinsSubSet.ToHashSet();
2020
for (int i = 0; i < 3; i++)
2121
{
2222
joinsSubSet[i].Article = articleTagsSubset;
@@ -27,7 +27,7 @@ public sealed class ManyToMany_OnReturnTests : HooksTestsSetup
2727
var completeJoin = _articleTagFaker.Generate(6);
2828

2929
var articleWithAllTags = _articleFaker.Generate();
30-
articleWithAllTags.ArticleTags = completeJoin;
30+
articleWithAllTags.ArticleTags = completeJoin.ToHashSet();
3131

3232
for (int i = 0; i < 6; i++)
3333
{

test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ protected HashSet<TodoItem> CreateTodoWithOwner()
8686
var tagsSubset = _tagFaker.Generate(3);
8787
var joinsSubSet = _articleTagFaker.Generate(3);
8888
var articleTagsSubset = _articleFaker.Generate();
89-
articleTagsSubset.ArticleTags = joinsSubSet;
89+
articleTagsSubset.ArticleTags = joinsSubSet.ToHashSet();
9090
for (int i = 0; i < 3; i++)
9191
{
9292
joinsSubSet[i].Article = articleTagsSubset;
@@ -97,7 +97,7 @@ protected HashSet<TodoItem> CreateTodoWithOwner()
9797
var completeJoin = _articleTagFaker.Generate(6);
9898

9999
var articleWithAllTags = _articleFaker.Generate();
100-
articleWithAllTags.ArticleTags = completeJoin;
100+
articleWithAllTags.ArticleTags = completeJoin.ToHashSet();
101101

102102
for (int i = 0; i < 6; i++)
103103
{
@@ -116,7 +116,7 @@ protected HashSet<TodoItem> CreateTodoWithOwner()
116116
var tagsSubset = _tagFaker.Generate(3);
117117
var joinsSubSet = _identifiableArticleTagFaker.Generate(3);
118118
var articleTagsSubset = _articleFaker.Generate();
119-
articleTagsSubset.IdentifiableArticleTags = joinsSubSet;
119+
articleTagsSubset.IdentifiableArticleTags = joinsSubSet.ToHashSet();
120120
for (int i = 0; i < 3; i++)
121121
{
122122
joinsSubSet[i].Article = articleTagsSubset;
@@ -126,7 +126,7 @@ protected HashSet<TodoItem> CreateTodoWithOwner()
126126
var completeJoin = _identifiableArticleTagFaker.Generate(6);
127127

128128
var articleWithAllTags = _articleFaker.Generate();
129-
articleWithAllTags.IdentifiableArticleTags = joinsSubSet;
129+
articleWithAllTags.IdentifiableArticleTags = joinsSubSet.ToHashSet();
130130

131131
for (int i = 0; i < 6; i++)
132132
{

0 commit comments

Comments
 (0)