Skip to content

Feat/#151: Many-to-Many Support via [HasManyThrough] #419

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Oct 3, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
.DS_Store

## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using JsonApiDotNetCore.Controllers;
using JsonApiDotNetCore.Services;
using JsonApiDotNetCoreExample.Models;

namespace JsonApiDotNetCoreExample.Controllers
{
public class ArticlesController : JsonApiController<Article>
{
public ArticlesController(
IJsonApiContext jsonApiContext,
IResourceService<Article> resourceService)
: base(jsonApiContext, resourceService)
{ }
}
}
9 changes: 6 additions & 3 deletions src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
modelBuilder.Entity<CourseStudentEntity>()
.HasOne(r => r.Course)
.WithMany(c => c.Students)
.HasForeignKey(r => r.CourseId)
;
.HasForeignKey(r => r.CourseId);

modelBuilder.Entity<CourseStudentEntity>()
.HasOne(r => r.Student)
.WithMany(s => s.Courses)
.HasForeignKey(r => r.StudentId);

modelBuilder.Entity<ArticleTag>()
.HasKey(bc => new { bc.ArticleId, bc.TagId });
}

public DbSet<TodoItem> TodoItems { get; set; }
Expand All @@ -53,7 +55,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
public DbSet<DepartmentEntity> Departments { get; set; }
public DbSet<CourseStudentEntity> Registrations { get; set; }
public DbSet<StudentEntity> Students { get; set; }

public DbSet<PersonRole> PersonRoles { get; set; }
public DbSet<ArticleTag> ArticleTags { get; set; }
public DbSet<Tag> Tags { get; set; }
}
}
7 changes: 7 additions & 0 deletions src/Examples/JsonApiDotNetCoreExample/Models/Article.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using JsonApiDotNetCore.Models;

namespace JsonApiDotNetCoreExample.Models
Expand All @@ -10,5 +12,10 @@ public class Article : Identifiable
[HasOne("author")]
public Author Author { get; set; }
public int AuthorId { get; set; }

[NotMapped]
[HasManyThrough(nameof(ArticleTags))]
public List<Tag> Tags { get; set; }
public List<ArticleTag> ArticleTags { get; set; }
}
}
11 changes: 11 additions & 0 deletions src/Examples/JsonApiDotNetCoreExample/Models/ArticleTag.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace JsonApiDotNetCoreExample.Models
{
public class ArticleTag
{
public int ArticleId { get; set; }
public Article Article { get; set; }

public int TagId { get; set; }
public Tag Tag { get; set; }
}
}
9 changes: 9 additions & 0 deletions src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using JsonApiDotNetCore.Models;

namespace JsonApiDotNetCoreExample.Models
{
public class Tag : Identifiable
{
public string Name { get; set; }
}
}
41 changes: 41 additions & 0 deletions src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
Expand Down Expand Up @@ -166,7 +167,47 @@ protected virtual List<RelationshipAttribute> GetRelationships(Type entityType)
attribute.InternalRelationshipName = prop.Name;
attribute.Type = GetRelationshipType(attribute, prop);
attributes.Add(attribute);

if(attribute is HasManyThroughAttribute hasManyThroughAttribute) {
var throughProperty = properties.SingleOrDefault(p => p.Name == hasManyThroughAttribute.InternalThroughName);
if(throughProperty == null)
throw new JsonApiSetupException($"Invalid '{nameof(HasManyThroughAttribute)}' on type '{entityType}'. Type does not contain a property named '{hasManyThroughAttribute.InternalThroughName}'.");

if(throughProperty.PropertyType.Implements<IList>() == false)
throw new JsonApiSetupException($"Invalid '{nameof(HasManyThroughAttribute)}' on type '{entityType}.{throughProperty.Name}'. Property type does not implement IList.");

// assumption: the property should be a generic collection, e.g. List<ArticleTag>
if(throughProperty.PropertyType.IsGenericType == false)
throw new JsonApiSetupException($"Invalid '{nameof(HasManyThroughAttribute)}' on type '{entityType}'. Expected through entity to be a generic type, such as List<{prop.PropertyType}>.");

// Article → List<ArticleTag>
hasManyThroughAttribute.ThroughProperty = throughProperty;

// ArticleTag
hasManyThroughAttribute.ThroughType = throughProperty.PropertyType.GetGenericArguments()[0];

var throughProperties = hasManyThroughAttribute.ThroughType.GetProperties();

// ArticleTag.Article
hasManyThroughAttribute.LeftProperty = throughProperties.SingleOrDefault(x => x.PropertyType == entityType)
?? throw new JsonApiSetupException($"{hasManyThroughAttribute.ThroughType} does not contain a navigation property to type {entityType}");

// ArticleTag.ArticleId
var leftIdPropertyName = JsonApiOptions.RelatedIdMapper.GetRelatedIdPropertyName(hasManyThroughAttribute.LeftProperty.Name);
hasManyThroughAttribute.LeftIdProperty = throughProperties.SingleOrDefault(x => x.Name == leftIdPropertyName)
?? throw new JsonApiSetupException($"{hasManyThroughAttribute.ThroughType} does not contain a relationship id property to type {entityType} with name {leftIdPropertyName}");

// Article → ArticleTag.Tag
hasManyThroughAttribute.RightProperty = throughProperties.SingleOrDefault(x => x.PropertyType == hasManyThroughAttribute.Type)
?? throw new JsonApiSetupException($"{hasManyThroughAttribute.ThroughType} does not contain a navigation property to type {hasManyThroughAttribute.Type}");

// ArticleTag.TagId
var rightIdPropertyName = JsonApiOptions.RelatedIdMapper.GetRelatedIdPropertyName(hasManyThroughAttribute.RightProperty.Name);
hasManyThroughAttribute.RightIdProperty = throughProperties.SingleOrDefault(x => x.Name == rightIdPropertyName)
?? throw new JsonApiSetupException($"{hasManyThroughAttribute.ThroughType} does not contain a relationship id property to type {hasManyThroughAttribute.Type} with name {rightIdPropertyName}");
}
}

return attributes;
}

Expand Down
33 changes: 18 additions & 15 deletions src/JsonApiDotNetCore/Builders/DocumentBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ public class DocumentBuilder : IDocumentBuilder
private readonly IScopedServiceProvider _scopedServiceProvider;

public DocumentBuilder(
IJsonApiContext jsonApiContext,
IRequestMeta requestMeta = null,
IJsonApiContext jsonApiContext,
IRequestMeta requestMeta = null,
IDocumentBuilderOptionsProvider documentBuilderOptionsProvider = null,
IScopedServiceProvider scopedServiceProvider = null)
{
Expand Down Expand Up @@ -112,7 +112,8 @@ public ResourceObject GetData(ContextEntity contextEntity, IIdentifiable entity)

public ResourceObject GetData(ContextEntity contextEntity, IIdentifiable entity, IResourceDefinition resourceDefinition = null)
{
var data = new ResourceObject {
var data = new ResourceObject
{
Type = contextEntity.EntityName,
Id = entity.StringId
};
Expand Down Expand Up @@ -178,7 +179,7 @@ private RelationshipData GetRelationshipData(RelationshipAttribute attr, Context
}

// this only includes the navigation property, we need to actually check the navigation property Id
var navigationEntity = _jsonApiContext.ContextGraph.GetRelationship(entity, attr.InternalRelationshipName);
var navigationEntity = _jsonApiContext.ContextGraph.GetRelationshipValue(entity, attr);
if (navigationEntity == null)
relationshipData.SingleData = attr.IsHasOne
? GetIndependentRelationshipIdentifier((HasOneAttribute)attr, entity)
Expand All @@ -195,14 +196,14 @@ private List<ResourceObject> GetIncludedEntities(List<ResourceObject> included,
{
if (_jsonApiContext.IncludedRelationships != null)
{
foreach(var relationshipName in _jsonApiContext.IncludedRelationships)
foreach (var relationshipName in _jsonApiContext.IncludedRelationships)
{
var relationshipChain = relationshipName.Split('.');

var contextEntity = rootContextEntity;
var entity = rootResource;
included = IncludeRelationshipChain(included, rootContextEntity, rootResource, relationshipChain, 0);
}
}
}

return included;
Expand Down Expand Up @@ -234,15 +235,15 @@ private List<ResourceObject> IncludeRelationshipChain(
private List<ResourceObject> IncludeSingleResourceRelationships(
List<ResourceObject> included, IIdentifiable navigationEntity, RelationshipAttribute relationship, string[] relationshipChain, int relationshipChainIndex)
{
if (relationshipChainIndex < relationshipChain.Length)
if (relationshipChainIndex < relationshipChain.Length)
{
var nextContextEntity = _jsonApiContext.ContextGraph.GetContextEntity(relationship.Type);
var resource = (IIdentifiable)navigationEntity;
// recursive call
if(relationshipChainIndex < relationshipChain.Length - 1)
if (relationshipChainIndex < relationshipChain.Length - 1)
included = IncludeRelationshipChain(included, nextContextEntity, resource, relationshipChain, relationshipChainIndex + 1);
}

return included;
}

Expand Down Expand Up @@ -284,16 +285,18 @@ private ResourceObject GetIncludedEntity(IIdentifiable entity)

private List<ResourceIdentifierObject> GetRelationships(IEnumerable<object> entities)
{
var objType = entities.GetElementType();

var typeName = _jsonApiContext.ContextGraph.GetContextEntity(objType);

string typeName = null;
var relationships = new List<ResourceIdentifierObject>();
foreach (var entity in entities)
{
// this method makes the assumption that entities is a homogenous collection
// so, we just lookup the type of the first entity on the graph
// this is better than trying to get it from the generic parameter since it could
// be less specific than what is registered on the graph (e.g. IEnumerable<object>)
typeName = typeName ?? _jsonApiContext.ContextGraph.GetContextEntity(entity.GetType()).EntityName;
relationships.Add(new ResourceIdentifierObject
{
Type = typeName.EntityName,
Type = typeName,
Id = ((IIdentifiable)entity).StringId
});
}
Expand All @@ -305,7 +308,7 @@ private ResourceIdentifierObject GetRelationship(object entity)
var objType = entity.GetType();
var contextEntity = _jsonApiContext.ContextGraph.GetContextEntity(objType);

if(entity is IIdentifiable identifiableEntity)
if (entity is IIdentifiable identifiableEntity)
return new ResourceIdentifierObject
{
Type = contextEntity.EntityName,
Expand Down
5 changes: 5 additions & 0 deletions src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ public class JsonApiOptions
/// </summary>
public static IResourceNameFormatter ResourceNameFormatter { get; set; } = new DefaultResourceNameFormatter();

/// <summary>
/// Provides an interface for formatting relationship id properties given the navigation property name
/// </summary>
public static IRelatedIdMapper RelatedIdMapper { get; set; } = new DefaultRelatedIdMapper();

/// <summary>
/// Whether or not stack traces should be serialized in Error objects
/// </summary>
Expand Down
60 changes: 49 additions & 11 deletions src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
Expand Down Expand Up @@ -143,17 +144,17 @@ public virtual async Task<TEntity> GetAndIncludeAsync(TId id, string relationshi
/// <inheritdoc />
public virtual async Task<TEntity> CreateAsync(TEntity entity)
{
AttachRelationships();
AttachRelationships(entity);
_dbSet.Add(entity);

await _context.SaveChangesAsync();

return entity;
}

protected virtual void AttachRelationships()
protected virtual void AttachRelationships(TEntity entity = null)
{
AttachHasManyPointers();
AttachHasManyPointers(entity);
AttachHasOnePointers();
}

Expand Down Expand Up @@ -183,15 +184,42 @@ public void DetachRelationshipPointers(TEntity entity)
/// This is used to allow creation of HasMany relationships when the
/// dependent side of the relationship already exists.
/// </summary>
private void AttachHasManyPointers()
private void AttachHasManyPointers(TEntity entity)
{
var relationships = _jsonApiContext.HasManyRelationshipPointers.Get();
foreach (var relationship in relationships)
{
foreach (var pointer in relationship.Value)
{
_context.Entry(pointer).State = EntityState.Unchanged;
}
if(relationship.Key is HasManyThroughAttribute hasManyThrough)
AttachHasManyThrough(entity, hasManyThrough, relationship.Value);
else
AttachHasMany(relationship.Key as HasManyAttribute, relationship.Value);
}
}

private void AttachHasMany(HasManyAttribute relationship, IList pointers)
{
foreach (var pointer in pointers)
_context.Entry(pointer).State = EntityState.Unchanged;
}

private void AttachHasManyThrough(TEntity entity, HasManyThroughAttribute hasManyThrough, IList pointers)
{
// create the collection (e.g. List<ArticleTag>)
// this type MUST implement IList so we can build the collection
// if this is problematic, we _could_ reflect on the type and find an Add method
// or we might be able to create a proxy type and implement the enumerator
var throughRelationshipCollection = Activator.CreateInstance(hasManyThrough.ThroughProperty.PropertyType) as IList;
hasManyThrough.ThroughProperty.SetValue(entity, throughRelationshipCollection);

foreach (var pointer in pointers)
{
_context.Entry(pointer).State = EntityState.Unchanged;
var throughInstance = Activator.CreateInstance(hasManyThrough.ThroughType);

hasManyThrough.LeftProperty.SetValue(throughInstance, entity);
hasManyThrough.RightProperty.SetValue(throughInstance, pointer);

throughRelationshipCollection.Add(throughInstance);
}
}

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

AttachRelationships(oldEntity);

await _context.SaveChangesAsync();

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

var genericProcessor = _genericProcessorFactory.GetProcessor<IGenericProcessor>(typeof(GenericProcessor<>), typeToUpdate);
await genericProcessor.UpdateRelationshipsAsync(parent, relationship, relationshipIds);
}

Expand Down Expand Up @@ -275,8 +313,8 @@ public virtual IQueryable<TEntity> Include(IQueryable<TEntity> entities, string
}

internalRelationshipPath = (internalRelationshipPath == null)
? relationship.InternalRelationshipName
: $"{internalRelationshipPath}.{relationship.InternalRelationshipName}";
? relationship.RelationshipPath
: $"{internalRelationshipPath}.{relationship.RelationshipPath}";

if(i < relationshipChain.Length)
entity = _jsonApiContext.ContextGraph.GetContextEntity(relationship.Type);
Expand Down
Loading