Skip to content

Commit 5049281

Browse files
authored
Merge pull request #419 from json-api-dotnet/feat/#151
Feat/#151: Many-to-Many Support via [HasManyThrough]
2 parents 84f45a9 + d779b0a commit 5049281

24 files changed

+933
-127
lines changed

Diff for: .gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
.DS_Store
2+
13
## Ignore Visual Studio temporary files, build results, and
24
## files generated by popular Visual Studio add-ons.
35

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
using JsonApiDotNetCore.Controllers;
2+
using JsonApiDotNetCore.Services;
3+
using JsonApiDotNetCoreExample.Models;
4+
5+
namespace JsonApiDotNetCoreExample.Controllers
6+
{
7+
public class ArticlesController : JsonApiController<Article>
8+
{
9+
public ArticlesController(
10+
IJsonApiContext jsonApiContext,
11+
IResourceService<Article> resourceService)
12+
: base(jsonApiContext, resourceService)
13+
{ }
14+
}
15+
}

Diff for: src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs

+6-3
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
3131
modelBuilder.Entity<CourseStudentEntity>()
3232
.HasOne(r => r.Course)
3333
.WithMany(c => c.Students)
34-
.HasForeignKey(r => r.CourseId)
35-
;
34+
.HasForeignKey(r => r.CourseId);
3635

3736
modelBuilder.Entity<CourseStudentEntity>()
3837
.HasOne(r => r.Student)
3938
.WithMany(s => s.Courses)
4039
.HasForeignKey(r => r.StudentId);
40+
41+
modelBuilder.Entity<ArticleTag>()
42+
.HasKey(bc => new { bc.ArticleId, bc.TagId });
4143
}
4244

4345
public DbSet<TodoItem> TodoItems { get; set; }
@@ -53,7 +55,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
5355
public DbSet<DepartmentEntity> Departments { get; set; }
5456
public DbSet<CourseStudentEntity> Registrations { get; set; }
5557
public DbSet<StudentEntity> Students { get; set; }
56-
5758
public DbSet<PersonRole> PersonRoles { get; set; }
59+
public DbSet<ArticleTag> ArticleTags { get; set; }
60+
public DbSet<Tag> Tags { get; set; }
5861
}
5962
}

Diff for: src/Examples/JsonApiDotNetCoreExample/Models/Article.cs

+7
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using System.Collections.Generic;
2+
using System.ComponentModel.DataAnnotations.Schema;
13
using JsonApiDotNetCore.Models;
24

35
namespace JsonApiDotNetCoreExample.Models
@@ -10,5 +12,10 @@ public class Article : Identifiable
1012
[HasOne("author")]
1113
public Author Author { get; set; }
1214
public int AuthorId { get; set; }
15+
16+
[NotMapped]
17+
[HasManyThrough(nameof(ArticleTags))]
18+
public List<Tag> Tags { get; set; }
19+
public List<ArticleTag> ArticleTags { get; set; }
1320
}
1421
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace JsonApiDotNetCoreExample.Models
2+
{
3+
public class ArticleTag
4+
{
5+
public int ArticleId { get; set; }
6+
public Article Article { get; set; }
7+
8+
public int TagId { get; set; }
9+
public Tag Tag { get; set; }
10+
}
11+
}

Diff for: src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using JsonApiDotNetCore.Models;
2+
3+
namespace JsonApiDotNetCoreExample.Models
4+
{
5+
public class Tag : Identifiable
6+
{
7+
public string Name { get; set; }
8+
}
9+
}

Diff for: src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs

+41
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Collections;
23
using System.Collections.Generic;
34
using System.Linq;
45
using System.Reflection;
@@ -166,7 +167,47 @@ protected virtual List<RelationshipAttribute> GetRelationships(Type entityType)
166167
attribute.InternalRelationshipName = prop.Name;
167168
attribute.Type = GetRelationshipType(attribute, prop);
168169
attributes.Add(attribute);
170+
171+
if(attribute is HasManyThroughAttribute hasManyThroughAttribute) {
172+
var throughProperty = properties.SingleOrDefault(p => p.Name == hasManyThroughAttribute.InternalThroughName);
173+
if(throughProperty == null)
174+
throw new JsonApiSetupException($"Invalid '{nameof(HasManyThroughAttribute)}' on type '{entityType}'. Type does not contain a property named '{hasManyThroughAttribute.InternalThroughName}'.");
175+
176+
if(throughProperty.PropertyType.Implements<IList>() == false)
177+
throw new JsonApiSetupException($"Invalid '{nameof(HasManyThroughAttribute)}' on type '{entityType}.{throughProperty.Name}'. Property type does not implement IList.");
178+
179+
// assumption: the property should be a generic collection, e.g. List<ArticleTag>
180+
if(throughProperty.PropertyType.IsGenericType == false)
181+
throw new JsonApiSetupException($"Invalid '{nameof(HasManyThroughAttribute)}' on type '{entityType}'. Expected through entity to be a generic type, such as List<{prop.PropertyType}>.");
182+
183+
// Article → List<ArticleTag>
184+
hasManyThroughAttribute.ThroughProperty = throughProperty;
185+
186+
// ArticleTag
187+
hasManyThroughAttribute.ThroughType = throughProperty.PropertyType.GetGenericArguments()[0];
188+
189+
var throughProperties = hasManyThroughAttribute.ThroughType.GetProperties();
190+
191+
// ArticleTag.Article
192+
hasManyThroughAttribute.LeftProperty = throughProperties.SingleOrDefault(x => x.PropertyType == entityType)
193+
?? throw new JsonApiSetupException($"{hasManyThroughAttribute.ThroughType} does not contain a navigation property to type {entityType}");
194+
195+
// ArticleTag.ArticleId
196+
var leftIdPropertyName = JsonApiOptions.RelatedIdMapper.GetRelatedIdPropertyName(hasManyThroughAttribute.LeftProperty.Name);
197+
hasManyThroughAttribute.LeftIdProperty = throughProperties.SingleOrDefault(x => x.Name == leftIdPropertyName)
198+
?? throw new JsonApiSetupException($"{hasManyThroughAttribute.ThroughType} does not contain a relationship id property to type {entityType} with name {leftIdPropertyName}");
199+
200+
// Article → ArticleTag.Tag
201+
hasManyThroughAttribute.RightProperty = throughProperties.SingleOrDefault(x => x.PropertyType == hasManyThroughAttribute.Type)
202+
?? throw new JsonApiSetupException($"{hasManyThroughAttribute.ThroughType} does not contain a navigation property to type {hasManyThroughAttribute.Type}");
203+
204+
// ArticleTag.TagId
205+
var rightIdPropertyName = JsonApiOptions.RelatedIdMapper.GetRelatedIdPropertyName(hasManyThroughAttribute.RightProperty.Name);
206+
hasManyThroughAttribute.RightIdProperty = throughProperties.SingleOrDefault(x => x.Name == rightIdPropertyName)
207+
?? throw new JsonApiSetupException($"{hasManyThroughAttribute.ThroughType} does not contain a relationship id property to type {hasManyThroughAttribute.Type} with name {rightIdPropertyName}");
208+
}
169209
}
210+
170211
return attributes;
171212
}
172213

Diff for: src/JsonApiDotNetCore/Builders/DocumentBuilder.cs

+18-15
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ public class DocumentBuilder : IDocumentBuilder
1818
private readonly IScopedServiceProvider _scopedServiceProvider;
1919

2020
public DocumentBuilder(
21-
IJsonApiContext jsonApiContext,
22-
IRequestMeta requestMeta = null,
21+
IJsonApiContext jsonApiContext,
22+
IRequestMeta requestMeta = null,
2323
IDocumentBuilderOptionsProvider documentBuilderOptionsProvider = null,
2424
IScopedServiceProvider scopedServiceProvider = null)
2525
{
@@ -112,7 +112,8 @@ public ResourceObject GetData(ContextEntity contextEntity, IIdentifiable entity)
112112

113113
public ResourceObject GetData(ContextEntity contextEntity, IIdentifiable entity, IResourceDefinition resourceDefinition = null)
114114
{
115-
var data = new ResourceObject {
115+
var data = new ResourceObject
116+
{
116117
Type = contextEntity.EntityName,
117118
Id = entity.StringId
118119
};
@@ -178,7 +179,7 @@ private RelationshipData GetRelationshipData(RelationshipAttribute attr, Context
178179
}
179180

180181
// this only includes the navigation property, we need to actually check the navigation property Id
181-
var navigationEntity = _jsonApiContext.ContextGraph.GetRelationship(entity, attr.InternalRelationshipName);
182+
var navigationEntity = _jsonApiContext.ContextGraph.GetRelationshipValue(entity, attr);
182183
if (navigationEntity == null)
183184
relationshipData.SingleData = attr.IsHasOne
184185
? GetIndependentRelationshipIdentifier((HasOneAttribute)attr, entity)
@@ -195,14 +196,14 @@ private List<ResourceObject> GetIncludedEntities(List<ResourceObject> included,
195196
{
196197
if (_jsonApiContext.IncludedRelationships != null)
197198
{
198-
foreach(var relationshipName in _jsonApiContext.IncludedRelationships)
199+
foreach (var relationshipName in _jsonApiContext.IncludedRelationships)
199200
{
200201
var relationshipChain = relationshipName.Split('.');
201202

202203
var contextEntity = rootContextEntity;
203204
var entity = rootResource;
204205
included = IncludeRelationshipChain(included, rootContextEntity, rootResource, relationshipChain, 0);
205-
}
206+
}
206207
}
207208

208209
return included;
@@ -234,15 +235,15 @@ private List<ResourceObject> IncludeRelationshipChain(
234235
private List<ResourceObject> IncludeSingleResourceRelationships(
235236
List<ResourceObject> included, IIdentifiable navigationEntity, RelationshipAttribute relationship, string[] relationshipChain, int relationshipChainIndex)
236237
{
237-
if (relationshipChainIndex < relationshipChain.Length)
238+
if (relationshipChainIndex < relationshipChain.Length)
238239
{
239240
var nextContextEntity = _jsonApiContext.ContextGraph.GetContextEntity(relationship.Type);
240241
var resource = (IIdentifiable)navigationEntity;
241242
// recursive call
242-
if(relationshipChainIndex < relationshipChain.Length - 1)
243+
if (relationshipChainIndex < relationshipChain.Length - 1)
243244
included = IncludeRelationshipChain(included, nextContextEntity, resource, relationshipChain, relationshipChainIndex + 1);
244245
}
245-
246+
246247
return included;
247248
}
248249

@@ -284,16 +285,18 @@ private ResourceObject GetIncludedEntity(IIdentifiable entity)
284285

285286
private List<ResourceIdentifierObject> GetRelationships(IEnumerable<object> entities)
286287
{
287-
var objType = entities.GetElementType();
288-
289-
var typeName = _jsonApiContext.ContextGraph.GetContextEntity(objType);
290-
288+
string typeName = null;
291289
var relationships = new List<ResourceIdentifierObject>();
292290
foreach (var entity in entities)
293291
{
292+
// this method makes the assumption that entities is a homogenous collection
293+
// so, we just lookup the type of the first entity on the graph
294+
// this is better than trying to get it from the generic parameter since it could
295+
// be less specific than what is registered on the graph (e.g. IEnumerable<object>)
296+
typeName = typeName ?? _jsonApiContext.ContextGraph.GetContextEntity(entity.GetType()).EntityName;
294297
relationships.Add(new ResourceIdentifierObject
295298
{
296-
Type = typeName.EntityName,
299+
Type = typeName,
297300
Id = ((IIdentifiable)entity).StringId
298301
});
299302
}
@@ -305,7 +308,7 @@ private ResourceIdentifierObject GetRelationship(object entity)
305308
var objType = entity.GetType();
306309
var contextEntity = _jsonApiContext.ContextGraph.GetContextEntity(objType);
307310

308-
if(entity is IIdentifiable identifiableEntity)
311+
if (entity is IIdentifiable identifiableEntity)
309312
return new ResourceIdentifierObject
310313
{
311314
Type = contextEntity.EntityName,

Diff for: src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs

+5
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ public class JsonApiOptions
2222
/// </summary>
2323
public static IResourceNameFormatter ResourceNameFormatter { get; set; } = new DefaultResourceNameFormatter();
2424

25+
/// <summary>
26+
/// Provides an interface for formatting relationship id properties given the navigation property name
27+
/// </summary>
28+
public static IRelatedIdMapper RelatedIdMapper { get; set; } = new DefaultRelatedIdMapper();
29+
2530
/// <summary>
2631
/// Whether or not stack traces should be serialized in Error objects
2732
/// </summary>

Diff for: src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs

+49-11
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Collections;
23
using System.Collections.Generic;
34
using System.Linq;
45
using System.Threading.Tasks;
@@ -143,17 +144,17 @@ public virtual async Task<TEntity> GetAndIncludeAsync(TId id, string relationshi
143144
/// <inheritdoc />
144145
public virtual async Task<TEntity> CreateAsync(TEntity entity)
145146
{
146-
AttachRelationships();
147+
AttachRelationships(entity);
147148
_dbSet.Add(entity);
148149

149150
await _context.SaveChangesAsync();
150151

151152
return entity;
152153
}
153154

154-
protected virtual void AttachRelationships()
155+
protected virtual void AttachRelationships(TEntity entity = null)
155156
{
156-
AttachHasManyPointers();
157+
AttachHasManyPointers(entity);
157158
AttachHasOnePointers();
158159
}
159160

@@ -183,15 +184,42 @@ public void DetachRelationshipPointers(TEntity entity)
183184
/// This is used to allow creation of HasMany relationships when the
184185
/// dependent side of the relationship already exists.
185186
/// </summary>
186-
private void AttachHasManyPointers()
187+
private void AttachHasManyPointers(TEntity entity)
187188
{
188189
var relationships = _jsonApiContext.HasManyRelationshipPointers.Get();
189190
foreach (var relationship in relationships)
190191
{
191-
foreach (var pointer in relationship.Value)
192-
{
193-
_context.Entry(pointer).State = EntityState.Unchanged;
194-
}
192+
if(relationship.Key is HasManyThroughAttribute hasManyThrough)
193+
AttachHasManyThrough(entity, hasManyThrough, relationship.Value);
194+
else
195+
AttachHasMany(relationship.Key as HasManyAttribute, relationship.Value);
196+
}
197+
}
198+
199+
private void AttachHasMany(HasManyAttribute relationship, IList pointers)
200+
{
201+
foreach (var pointer in pointers)
202+
_context.Entry(pointer).State = EntityState.Unchanged;
203+
}
204+
205+
private void AttachHasManyThrough(TEntity entity, HasManyThroughAttribute hasManyThrough, IList pointers)
206+
{
207+
// create the collection (e.g. List<ArticleTag>)
208+
// this type MUST implement IList so we can build the collection
209+
// if this is problematic, we _could_ reflect on the type and find an Add method
210+
// or we might be able to create a proxy type and implement the enumerator
211+
var throughRelationshipCollection = Activator.CreateInstance(hasManyThrough.ThroughProperty.PropertyType) as IList;
212+
hasManyThrough.ThroughProperty.SetValue(entity, throughRelationshipCollection);
213+
214+
foreach (var pointer in pointers)
215+
{
216+
_context.Entry(pointer).State = EntityState.Unchanged;
217+
var throughInstance = Activator.CreateInstance(hasManyThrough.ThroughType);
218+
219+
hasManyThrough.LeftProperty.SetValue(throughInstance, entity);
220+
hasManyThrough.RightProperty.SetValue(throughInstance, pointer);
221+
222+
throughRelationshipCollection.Add(throughInstance);
195223
}
196224
}
197225

@@ -221,6 +249,8 @@ public virtual async Task<TEntity> UpdateAsync(TId id, TEntity entity)
221249
foreach (var relationship in _jsonApiContext.RelationshipsToUpdate)
222250
relationship.Key.SetValue(oldEntity, relationship.Value);
223251

252+
AttachRelationships(oldEntity);
253+
224254
await _context.SaveChangesAsync();
225255

226256
return oldEntity;
@@ -229,7 +259,15 @@ public virtual async Task<TEntity> UpdateAsync(TId id, TEntity entity)
229259
/// <inheritdoc />
230260
public async Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IEnumerable<string> relationshipIds)
231261
{
232-
var genericProcessor = _genericProcessorFactory.GetProcessor<IGenericProcessor>(typeof(GenericProcessor<>), relationship.Type);
262+
// TODO: it would be better to let this be determined within the relationship attribute...
263+
// need to think about the right way to do that since HasMany doesn't need to think about this
264+
// and setting the HasManyThrough.Type to the join type (ArticleTag instead of Tag) for this changes the semantics
265+
// of the property...
266+
var typeToUpdate = (relationship is HasManyThroughAttribute hasManyThrough)
267+
? hasManyThrough.ThroughType
268+
: relationship.Type;
269+
270+
var genericProcessor = _genericProcessorFactory.GetProcessor<IGenericProcessor>(typeof(GenericProcessor<>), typeToUpdate);
233271
await genericProcessor.UpdateRelationshipsAsync(parent, relationship, relationshipIds);
234272
}
235273

@@ -275,8 +313,8 @@ public virtual IQueryable<TEntity> Include(IQueryable<TEntity> entities, string
275313
}
276314

277315
internalRelationshipPath = (internalRelationshipPath == null)
278-
? relationship.InternalRelationshipName
279-
: $"{internalRelationshipPath}.{relationship.InternalRelationshipName}";
316+
? relationship.RelationshipPath
317+
: $"{internalRelationshipPath}.{relationship.RelationshipPath}";
280318

281319
if(i < relationshipChain.Length)
282320
entity = _jsonApiContext.ContextGraph.GetContextEntity(relationship.Type);

0 commit comments

Comments
 (0)