diff --git a/.gitignore b/.gitignore index 0ca27f04e1..b6767ff903 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.DS_Store + ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/ArticlesController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/ArticlesController.cs new file mode 100644 index 0000000000..95aa7d69f9 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/ArticlesController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample.Models; + +namespace JsonApiDotNetCoreExample.Controllers +{ + public class ArticlesController : JsonApiController
+ { + public ArticlesController( + IJsonApiContext jsonApiContext, + IResourceService
resourceService) + : base(jsonApiContext, resourceService) + { } + } +} \ No newline at end of file diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index cee66678ab..5486eedb6f 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -31,13 +31,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .HasOne(r => r.Course) .WithMany(c => c.Students) - .HasForeignKey(r => r.CourseId) - ; + .HasForeignKey(r => r.CourseId); modelBuilder.Entity() .HasOne(r => r.Student) .WithMany(s => s.Courses) .HasForeignKey(r => r.StudentId); + + modelBuilder.Entity() + .HasKey(bc => new { bc.ArticleId, bc.TagId }); } public DbSet TodoItems { get; set; } @@ -53,7 +55,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public DbSet Departments { get; set; } public DbSet Registrations { get; set; } public DbSet Students { get; set; } - public DbSet PersonRoles { get; set; } + public DbSet ArticleTags { get; set; } + public DbSet Tags { get; set; } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs index c633d58bdd..8d4d310f70 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; using JsonApiDotNetCore.Models; namespace JsonApiDotNetCoreExample.Models @@ -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 Tags { get; set; } + public List ArticleTags { get; set; } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/ArticleTag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/ArticleTag.cs new file mode 100644 index 0000000000..992e688c51 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Models/ArticleTag.cs @@ -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; } + } +} \ No newline at end of file diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs new file mode 100644 index 0000000000..ebd3bd5a1c --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs @@ -0,0 +1,9 @@ +using JsonApiDotNetCore.Models; + +namespace JsonApiDotNetCoreExample.Models +{ + public class Tag : Identifiable + { + public string Name { get; set; } + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs b/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs index b343243463..88670821fe 100644 --- a/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Linq; using System.Reflection; @@ -166,7 +167,47 @@ protected virtual List 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() == 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 + 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 + 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; } diff --git a/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs b/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs index 6afa13e029..730dd773c4 100644 --- a/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs @@ -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) { @@ -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 }; @@ -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) @@ -195,14 +196,14 @@ private List GetIncludedEntities(List 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; @@ -234,15 +235,15 @@ private List IncludeRelationshipChain( private List IncludeSingleResourceRelationships( List 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; } @@ -284,16 +285,18 @@ private ResourceObject GetIncludedEntity(IIdentifiable entity) private List GetRelationships(IEnumerable entities) { - var objType = entities.GetElementType(); - - var typeName = _jsonApiContext.ContextGraph.GetContextEntity(objType); - + string typeName = null; var relationships = new List(); 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) + typeName = typeName ?? _jsonApiContext.ContextGraph.GetContextEntity(entity.GetType()).EntityName; relationships.Add(new ResourceIdentifierObject { - Type = typeName.EntityName, + Type = typeName, Id = ((IIdentifiable)entity).StringId }); } @@ -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, diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index 66386aac19..e4d61e3864 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -22,6 +22,11 @@ public class JsonApiOptions /// public static IResourceNameFormatter ResourceNameFormatter { get; set; } = new DefaultResourceNameFormatter(); + /// + /// Provides an interface for formatting relationship id properties given the navigation property name + /// + public static IRelatedIdMapper RelatedIdMapper { get; set; } = new DefaultRelatedIdMapper(); + /// /// Whether or not stack traces should be serialized in Error objects /// diff --git a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs index 808c1929a4..c0c7c93192 100644 --- a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs +++ b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -143,7 +144,7 @@ public virtual async Task GetAndIncludeAsync(TId id, string relationshi /// public virtual async Task CreateAsync(TEntity entity) { - AttachRelationships(); + AttachRelationships(entity); _dbSet.Add(entity); await _context.SaveChangesAsync(); @@ -151,9 +152,9 @@ public virtual async Task CreateAsync(TEntity entity) return entity; } - protected virtual void AttachRelationships() + protected virtual void AttachRelationships(TEntity entity = null) { - AttachHasManyPointers(); + AttachHasManyPointers(entity); AttachHasOnePointers(); } @@ -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. /// - 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) + // 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); } } @@ -221,6 +249,8 @@ public virtual async Task UpdateAsync(TId id, TEntity entity) foreach (var relationship in _jsonApiContext.RelationshipsToUpdate) relationship.Key.SetValue(oldEntity, relationship.Value); + AttachRelationships(oldEntity); + await _context.SaveChangesAsync(); return oldEntity; @@ -229,7 +259,15 @@ public virtual async Task UpdateAsync(TId id, TEntity entity) /// public async Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IEnumerable relationshipIds) { - var genericProcessor = _genericProcessorFactory.GetProcessor(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(typeof(GenericProcessor<>), typeToUpdate); await genericProcessor.UpdateRelationshipsAsync(parent, relationship, relationshipIds); } @@ -275,8 +313,8 @@ public virtual IQueryable Include(IQueryable 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); diff --git a/src/JsonApiDotNetCore/Extensions/DbContextExtensions.cs b/src/JsonApiDotNetCore/Extensions/DbContextExtensions.cs index 9176474548..c4b059f403 100644 --- a/src/JsonApiDotNetCore/Extensions/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/DbContextExtensions.cs @@ -1,7 +1,10 @@ using System; using System.Linq; +using System.Threading.Tasks; using JsonApiDotNetCore.Models; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; namespace JsonApiDotNetCore.Extensions { @@ -11,6 +14,16 @@ public static class DbContextExtensions public static DbSet GetDbSet(this DbContext context) where T : class => context.Set(); + /// + /// Get the DbSet when the model type is unknown until runtime + /// + public static IQueryable Set(this DbContext context, Type t) + => (IQueryable)context + .GetType() + .GetMethod("Set") + .MakeGenericMethod(t) // TODO: will caching help runtime performance? + .Invoke(context, null); + /// /// Determines whether or not EF is already tracking an entity of the same Type and Id /// @@ -28,5 +41,65 @@ public static bool EntityIsTracked(this DbContext context, IIdentifiable entity) return trackedEntries != null; } + + /// + /// Gets the current transaction or creates a new one. + /// If a transaction already exists, commit, rollback and dispose + /// will not be called. It is assumed the creator of the original + /// transaction should be responsible for disposal. + /// + /// + /// + /// + /// using(var transaction = _context.GetCurrentOrCreateTransaction()) + /// { + /// // perform multiple operations on the context and then save... + /// _context.SaveChanges(); + /// } + /// + /// + public static async Task GetCurrentOrCreateTransactionAsync(this DbContext context) + => await SafeTransactionProxy.GetOrCreateAsync(context.Database); + } + + /// + /// Gets the current transaction or creates a new one. + /// If a transaction already exists, commit, rollback and dispose + /// will not be called. It is assumed the creator of the original + /// transaction should be responsible for disposal. + /// + internal struct SafeTransactionProxy : IDbContextTransaction + { + private readonly bool _shouldExecute; + private readonly IDbContextTransaction _transaction; + + private SafeTransactionProxy(IDbContextTransaction transaction, bool shouldExecute) + { + _transaction = transaction; + _shouldExecute = shouldExecute; + } + + public static async Task GetOrCreateAsync(DatabaseFacade databaseFacade) + => (databaseFacade.CurrentTransaction != null) + ? new SafeTransactionProxy(databaseFacade.CurrentTransaction, shouldExecute: false) + : new SafeTransactionProxy(await databaseFacade.BeginTransactionAsync(), shouldExecute: true); + + /// + public Guid TransactionId => _transaction.TransactionId; + + /// + public void Commit() => Proxy(t => t.Commit()); + + /// + public void Rollback() => Proxy(t => t.Rollback()); + + /// + public void Dispose() => Proxy(t => t.Dispose()); + + private void Proxy(Action func) + { + if(_shouldExecute) + func(_transaction); + } } } diff --git a/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs b/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs index 74c390e8d5..03874d9b76 100644 --- a/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs @@ -67,5 +67,29 @@ private static object CreateNewInstance(Type type) throw new JsonApiException(500, $"Type '{type}' cannot be instantiated using the default constructor.", e); } } + + /// + /// Whether or not a type implements an interface. + /// + public static bool Implements(this Type concreteType) + => Implements(concreteType, typeof(T)); + + /// + /// Whether or not a type implements an interface. + /// + public static bool Implements(this Type concreteType, Type interfaceType) + => interfaceType?.IsAssignableFrom(concreteType) == true; + + /// + /// Whether or not a type inherits a base type. + /// + public static bool Inherits(this Type concreteType) + => Inherits(concreteType, typeof(T)); + + /// + /// Whether or not a type inherits a base type. + /// + public static bool Inherits(this Type concreteType, Type interfaceType) + => interfaceType?.IsAssignableFrom(concreteType) == true; } } diff --git a/src/JsonApiDotNetCore/Graph/ResourceIdMapper.cs b/src/JsonApiDotNetCore/Graph/ResourceIdMapper.cs new file mode 100644 index 0000000000..7252d5c710 --- /dev/null +++ b/src/JsonApiDotNetCore/Graph/ResourceIdMapper.cs @@ -0,0 +1,27 @@ +namespace JsonApiDotNetCore.Graph +{ + /// + /// Provides an interface for formatting relationship identifiers from the navigation property name + /// + public interface IRelatedIdMapper + { + /// + /// Get the internal property name for the database mapped identifier property + /// + /// + /// + /// + /// DefaultResourceNameFormatter.FormatId("Article"); + /// // "ArticleId" + /// + /// + string GetRelatedIdPropertyName(string propertyName); + } + + /// + public class DefaultRelatedIdMapper : IRelatedIdMapper + { + /// + public string GetRelatedIdPropertyName(string propertyName) => propertyName + "Id"; + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Internal/ContextGraph.cs b/src/JsonApiDotNetCore/Internal/ContextGraph.cs index 4b6a310527..3ea6758365 100644 --- a/src/JsonApiDotNetCore/Internal/ContextGraph.cs +++ b/src/JsonApiDotNetCore/Internal/ContextGraph.cs @@ -1,6 +1,8 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Linq; +using JsonApiDotNetCore.Models; namespace JsonApiDotNetCore.Internal { @@ -17,8 +19,28 @@ public interface IContextGraph /// _graph.GetRelationship(todoItem, nameof(TodoItem.Owner)); /// /// + /// + /// In the case of a `HasManyThrough` relationship, it will not traverse the relationship + /// and will instead return the value of the shadow property (e.g. Articles.Tags). + /// If you want to traverse the relationship, you should use . + /// object GetRelationship(TParent resource, string propertyName); + /// + /// Gets the value of the navigation property (defined by the ) + /// on the provided instance. + /// In the case of `HasManyThrough` relationships, it will traverse the through entity and return the + /// value of the relationship on the other side of a join entity (e.g. Articles.ArticleTags.Tag). + /// + /// The resource instance + /// The attribute used to define the relationship. + /// + /// + /// _graph.GetRelationshipValue(todoItem, nameof(TodoItem.Owner)); + /// + /// + object GetRelationshipValue(TParent resource, RelationshipAttribute relationship) where TParent : IIdentifiable; + /// /// Get the internal navigation property name for the specified public /// relationship name. @@ -107,6 +129,29 @@ public object GetRelationship(TParent entity, string relationshipName) return navigationProperty.GetValue(entity); } + public object GetRelationshipValue(TParent resource, RelationshipAttribute relationship) where TParent : IIdentifiable + { + if(relationship is HasManyThroughAttribute hasManyThroughRelationship) + { + return GetHasManyThrough(resource, hasManyThroughRelationship); + } + + return GetRelationship(resource, relationship.InternalRelationshipName); + } + + private IEnumerable GetHasManyThrough(IIdentifiable parent, HasManyThroughAttribute hasManyThrough) + { + var throughProperty = GetRelationship(parent, hasManyThrough.InternalThroughName); + if (throughProperty is IEnumerable hasManyNavigationEntity) + { + foreach (var includedEntity in hasManyNavigationEntity) + { + var targetValue = hasManyThrough.RightProperty.GetValue(includedEntity) as IIdentifiable; + yield return targetValue; + } + } + } + /// public string GetRelationshipName(string relationshipName) { @@ -125,5 +170,5 @@ public string GetPublicAttributeName(string internalAttributeName) .SingleOrDefault(a => a.InternalAttributeName == internalAttributeName)? .PublicAttributeName; } - } + } } diff --git a/src/JsonApiDotNetCore/Internal/Generics/GenericProcessor.cs b/src/JsonApiDotNetCore/Internal/Generics/GenericProcessor.cs index 0a56fdfd4a..8a98745465 100644 --- a/src/JsonApiDotNetCore/Internal/Generics/GenericProcessor.cs +++ b/src/JsonApiDotNetCore/Internal/Generics/GenericProcessor.cs @@ -1,19 +1,29 @@ +using System; using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; using System.Threading.Tasks; using JsonApiDotNetCore.Data; +using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Models; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; namespace JsonApiDotNetCore.Internal.Generics { + // TODO: consider renaming to PatchRelationshipService (or something) public interface IGenericProcessor { Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IEnumerable relationshipIds); - void SetRelationships(object parent, RelationshipAttribute relationship, IEnumerable relationshipIds); } - public class GenericProcessor : IGenericProcessor where T : class, IIdentifiable + /// + /// A special processor that gets instantiated for a generic type (<T>) + /// when the actual type is not known until runtime. Specifically, this is used for updating + /// relationships. + /// + public class GenericProcessor : IGenericProcessor where T : class { private readonly DbContext _context; public GenericProcessor(IDbContextResolver contextResolver) @@ -21,25 +31,79 @@ public GenericProcessor(IDbContextResolver contextResolver) _context = contextResolver.GetContext(); } - public async Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IEnumerable relationshipIds) + public virtual async Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IEnumerable relationshipIds) { - SetRelationships(parent, relationship, relationshipIds); + if (relationship is HasManyThroughAttribute hasManyThrough && parent is IIdentifiable identifiableParent) + { + await SetHasManyThroughRelationshipAsync(identifiableParent, hasManyThrough, relationshipIds); + } + else + { + await SetRelationshipsAsync(parent, relationship, relationshipIds); + } + } - await _context.SaveChangesAsync(); + private async Task SetHasManyThroughRelationshipAsync(IIdentifiable identifiableParent, HasManyThroughAttribute hasManyThrough, IEnumerable relationshipIds) + { + // we need to create a transaction for the HasManyThrough case so we can get and remove any existing + // join entities and only commit if all operations are successful + using(var transaction = await _context.GetCurrentOrCreateTransactionAsync()) + { + // ArticleTag + ParameterExpression parameter = Expression.Parameter(hasManyThrough.ThroughType); + + // ArticleTag.ArticleId + Expression property = Expression.Property(parameter, hasManyThrough.LeftIdProperty); + + // article.Id + var parentId = TypeHelper.ConvertType(identifiableParent.StringId, hasManyThrough.LeftIdProperty.PropertyType); + Expression target = Expression.Constant(parentId); + + // ArticleTag.ArticleId.Equals(article.Id) + Expression equals = Expression.Call(property, "Equals", null, target); + + var lambda = Expression.Lambda>(equals, parameter); + + // TODO: we shouldn't need to do this instead we should try updating the existing? + // the challenge here is if a composite key is used, then we will fail to + // create due to a unique key violation + var oldLinks = _context + .Set() + .Where(lambda.Compile()) + .ToList(); + + _context.RemoveRange(oldLinks); + + var newLinks = relationshipIds.Select(x => { + var link = Activator.CreateInstance(hasManyThrough.ThroughType); + hasManyThrough.LeftIdProperty.SetValue(link, TypeHelper.ConvertType(parentId, hasManyThrough.LeftIdProperty.PropertyType)); + hasManyThrough.RightIdProperty.SetValue(link, TypeHelper.ConvertType(x, hasManyThrough.RightIdProperty.PropertyType)); + return link; + }); + + _context.AddRange(newLinks); + await _context.SaveChangesAsync(); + + transaction.Commit(); + } } - public void SetRelationships(object parent, RelationshipAttribute relationship, IEnumerable relationshipIds) + private async Task SetRelationshipsAsync(object parent, RelationshipAttribute relationship, IEnumerable relationshipIds) { if (relationship.IsHasMany) { - var entities = _context.Set().Where(x => relationshipIds.Contains(x.StringId)).ToList(); + // TODO: need to handle the failure mode when the relationship does not implement IIdentifiable + var entities = _context.Set().Where(x => relationshipIds.Contains(((IIdentifiable)x).StringId)).ToList(); relationship.SetValue(parent, entities); } else { - var entity = _context.Set().SingleOrDefault(x => relationshipIds.First() == x.StringId); + // TODO: need to handle the failure mode when the relationship does not implement IIdentifiable + var entity = _context.Set().SingleOrDefault(x => relationshipIds.First() == ((IIdentifiable)x).StringId); relationship.SetValue(parent, entity); } + + await _context.SaveChangesAsync(); } } } diff --git a/src/JsonApiDotNetCore/Models/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Models/HasManyThroughAttribute.cs new file mode 100644 index 0000000000..49e18a43b3 --- /dev/null +++ b/src/JsonApiDotNetCore/Models/HasManyThroughAttribute.cs @@ -0,0 +1,164 @@ +using System; +using System.Reflection; + +namespace JsonApiDotNetCore.Models +{ + /// + /// Create a HasMany relationship through a many-to-many join relationship. + /// This type can only be applied on types that implement IList. + /// + /// + /// + /// In the following example, we expose a relationship named "tags" + /// through the navigation property `ArticleTags`. + /// The `Tags` property is decorated as `NotMapped` so that EF does not try + /// to map this to a database relationship. + /// + /// [NotMapped] + /// [HasManyThrough("tags", nameof(ArticleTags))] + /// public List<Tag> Tags { get; set; } + /// public List<ArticleTag> ArticleTags { get; set; } + /// + /// + public class HasManyThroughAttribute : HasManyAttribute + { + /// + /// Create a HasMany relationship through a many-to-many join relationship. + /// The public name exposed through the API will be based on the configured convention. + /// + /// + /// The name of the navigation property that will be used to get the HasMany relationship + /// Which links are available. Defaults to + /// Whether or not this relationship can be included using the ?include=public-name query string + /// + /// + /// + /// [HasManyThrough(nameof(ArticleTags), documentLinks: Link.All, canInclude: true)] + /// + /// + public HasManyThroughAttribute(string internalThroughName, Link documentLinks = Link.All, bool canInclude = true) + : base(null, documentLinks, canInclude) + { + InternalThroughName = internalThroughName; + } + + /// + /// Create a HasMany relationship through a many-to-many join relationship. + /// + /// + /// The relationship name as exposed by the API + /// The name of the navigation property that will be used to get the HasMany relationship + /// Which links are available. Defaults to + /// Whether or not this relationship can be included using the ?include=public-name query string + /// + /// + /// + /// [HasManyThrough("tags", nameof(ArticleTags), documentLinks: Link.All, canInclude: true)] + /// + /// + public HasManyThroughAttribute(string publicName, string internalThroughName, Link documentLinks = Link.All, bool canInclude = true) + : base(publicName, documentLinks, canInclude) + { + InternalThroughName = internalThroughName; + } + + /// + /// The name of the join property on the parent resource. + /// + /// + /// In the `[HasManyThrough("tags", nameof(ArticleTags))]` example + /// this would be "ArticleTags". + /// + public string InternalThroughName { get; private set; } + + /// + /// The join type. + /// + /// + /// In the `[HasManyThrough("tags", nameof(ArticleTags))]` example + /// this would be `ArticleTag`. + /// + public Type ThroughType { get; internal set; } + + /// + /// The navigation property back to the parent resource from the join type. + /// + /// + /// + /// In the `[HasManyThrough("tags", nameof(ArticleTags))]` example + /// this would point to the `Article.ArticleTags.Article` property + /// + /// + /// public Article Article { get; set; } + /// + /// + /// + public PropertyInfo LeftProperty { get; internal set; } + + /// + /// The id property back to the parent resource from the join type. + /// + /// + /// + /// In the `[HasManyThrough("tags", nameof(ArticleTags))]` example + /// this would point to the `Article.ArticleTags.ArticleId` property + /// + /// + /// public int ArticleId { get; set; } + /// + /// + /// + public PropertyInfo LeftIdProperty { get; internal set; } + + /// + /// The navigation property to the related resource from the join type. + /// + /// + /// + /// In the `[HasManyThrough("tags", nameof(ArticleTags))]` example + /// this would point to the `Article.ArticleTags.Tag` property + /// + /// + /// public Tag Tag { get; set; } + /// + /// + /// + public PropertyInfo RightProperty { get; internal set; } + + /// + /// The id property to the related resource from the join type. + /// + /// + /// + /// In the `[HasManyThrough("tags", nameof(ArticleTags))]` example + /// this would point to the `Article.ArticleTags.TagId` property + /// + /// + /// public int TagId { get; set; } + /// + /// + /// + public PropertyInfo RightIdProperty { get; internal set; } + + /// + /// The join entity property on the parent resource. + /// + /// + /// + /// In the `[HasManyThrough("tags", nameof(ArticleTags))]` example + /// this would point to the `Article.ArticleTags` property + /// + /// + /// public List<ArticleTags> ArticleTags { get; set; } + /// + /// + /// + public PropertyInfo ThroughProperty { get; internal set; } + + /// + /// + /// "ArticleTags.Tag" + /// + public override string RelationshipPath => $"{InternalThroughName}.{RightProperty.Name}"; + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Models/HasOneAttribute.cs b/src/JsonApiDotNetCore/Models/HasOneAttribute.cs index 2d83c3dd69..4c145727eb 100644 --- a/src/JsonApiDotNetCore/Models/HasOneAttribute.cs +++ b/src/JsonApiDotNetCore/Models/HasOneAttribute.cs @@ -1,4 +1,5 @@ using System; +using JsonApiDotNetCore.Configuration; namespace JsonApiDotNetCore.Models { @@ -38,7 +39,7 @@ public HasOneAttribute(string publicName = null, Link documentLinks = Link.All, /// The independent resource identifier. /// public string IdentifiablePropertyName => string.IsNullOrWhiteSpace(_explicitIdentifiablePropertyName) - ? $"{InternalRelationshipName}Id" + ? JsonApiOptions.RelatedIdMapper.GetRelatedIdPropertyName(InternalRelationshipName) : _explicitIdentifiablePropertyName; /// diff --git a/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs b/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs index b479d3bb12..a93bcbc868 100644 --- a/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs @@ -1,4 +1,6 @@ using System; +using System.Reflection; +using JsonApiDotNetCore.Extensions; namespace JsonApiDotNetCore.Models { @@ -21,11 +23,11 @@ protected RelationshipAttribute(string publicName, Link documentLinks, bool canI /// /// /// - /// public List<Articles> Articles { get; set; } // Type => Article + /// public List<Tag> Tags { get; set; } // Type => Tag /// /// public Type Type { get; internal set; } - public bool IsHasMany => GetType() == typeof(HasManyAttribute); + public bool IsHasMany => GetType() == typeof(HasManyAttribute) || GetType().Inherits(typeof(HasManyAttribute)); public bool IsHasOne => GetType() == typeof(HasOneAttribute); public Link DocumentLinks { get; } = Link.All; public bool CanInclude { get; } @@ -78,5 +80,13 @@ public override bool Equals(object obj) /// public virtual bool Is(string publicRelationshipName) => string.Equals(publicRelationshipName, PublicRelationshipName, StringComparison.OrdinalIgnoreCase); + + /// + /// The internal navigation property path to the related entity. + /// + /// + /// In all cases except the HasManyThrough relationships, this will just be the . + /// + public virtual string RelationshipPath => InternalRelationshipName; } } diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs b/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs index 28fc9c1ae2..20ae99194f 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs @@ -283,6 +283,10 @@ private object SetHasManyRelationship(object entity, if (relationships.TryGetValue(relationshipName, out RelationshipData relationshipData)) { + if(relationshipData.IsHasMany == false) { + throw new JsonApiException(400, $"Cannot set HasMany relationship '{attr.PublicRelationshipName}'. Value must be a JSON array of Resource Identifier Objects."); + } + var data = (List)relationshipData.ExposedData; if (data == null) return entity; diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs new file mode 100644 index 0000000000..e483aa327c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs @@ -0,0 +1,222 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Bogus; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; +using Xunit; +using Person = JsonApiDotNetCoreExample.Models.Person; + +namespace JsonApiDotNetCoreExampleTests.Acceptance +{ + [Collection("WebHostCollection")] + public class ManyToManyTests + { + private static readonly Faker
_articleFaker = new Faker
() + .RuleFor(a => a.Name, f => f.Random.AlphaNumeric(10)) + .RuleFor(a => a.Author, f => new Author()); + private static readonly Faker _tagFaker = new Faker().RuleFor(a => a.Name, f => f.Random.AlphaNumeric(10)); + + private TestFixture _fixture; + public ManyToManyTests(TestFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task Can_Fetch_Many_To_Many_Through() + { + // arrange + var context = _fixture.GetService(); + var article = _articleFaker.Generate(); + var tag = _tagFaker.Generate(); + var articleTag = new ArticleTag { + Article = article, + Tag = tag + }; + context.ArticleTags.Add(articleTag); + await context.SaveChangesAsync(); + + var route = $"/api/v1/articles/{article.Id}?include=tags"; + + // act + var response = await _fixture.Client.GetAsync(route); + + // assert + var body = await response.Content.ReadAsStringAsync(); + Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); + + var articleResponse = _fixture.GetService().Deserialize
(body); + Assert.NotNull(articleResponse); + Assert.Equal(article.Id, articleResponse.Id); + + var tagResponse = Assert.Single(articleResponse.Tags); + Assert.Equal(tag.Id, tagResponse.Id); + } + + [Fact] + public async Task Can_Create_Many_To_Many() + { + // arrange + var context = _fixture.GetService(); + var tag = _tagFaker.Generate(); + var author = new Person(); + context.Tags.Add(tag); + context.People.Add(author); + await context.SaveChangesAsync(); + + var article = _articleFaker.Generate(); + + var route = "/api/v1/articles"; + var request = new HttpRequestMessage(new HttpMethod("POST"), route); + var content = new + { + data = new + { + type = "articles", + relationships = new Dictionary + { + { "author", new { + data = new + { + type = "people", + id = author.StringId + } + } }, + { "tags", new { + data = new dynamic[] + { + new { + type = "tags", + id = tag.StringId + } + } + } } + } + } + }; + + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + + // act + var response = await _fixture.Client.SendAsync(request); + + // assert + var body = await response.Content.ReadAsStringAsync(); + Assert.True(HttpStatusCode.Created == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); + + var articleResponse = _fixture.GetService().Deserialize
(body); + Assert.NotNull(articleResponse); + + var persistedArticle = await _fixture.Context.Articles + .Include(a => a.ArticleTags) + .SingleAsync(a => a.Id == articleResponse.Id); + + var persistedArticleTag = Assert.Single(persistedArticle.ArticleTags); + Assert.Equal(tag.Id, persistedArticleTag.TagId); + } + + [Fact] + public async Task Can_Update_Many_To_Many() + { + // arrange + var context = _fixture.GetService(); + var tag = _tagFaker.Generate(); + var article = _articleFaker.Generate(); + context.Tags.Add(tag); + context.Articles.Add(article); + await context.SaveChangesAsync(); + + var route = $"/api/v1/articles/{article.Id}"; + var request = new HttpRequestMessage(new HttpMethod("PATCH"), route); + var content = new + { + data = new + { + type = "articles", + id = article.StringId, + relationships = new Dictionary + { + { "tags", new { + data = new [] { new + { + type = "tags", + id = tag.StringId + } } + } } + } + } + }; + + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + + // act + var response = await _fixture.Client.SendAsync(request); + + // assert + var body = await response.Content.ReadAsStringAsync(); + Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); + + var articleResponse = _fixture.GetService().Deserialize
(body); + Assert.NotNull(articleResponse); + + _fixture.ReloadDbContext(); + var persistedArticle = await _fixture.Context.Articles + .Include(a => a.ArticleTags) + .SingleAsync(a => a.Id == articleResponse.Id); + + var persistedArticleTag = Assert.Single(persistedArticle.ArticleTags); + Assert.Equal(tag.Id, persistedArticleTag.TagId); + } + + [Fact] + public async Task Can_Update_Many_To_Many_Through_Relationship_Link() + { + // arrange + var context = _fixture.GetService(); + var tag = _tagFaker.Generate(); + var article = _articleFaker.Generate(); + context.Tags.Add(tag); + context.Articles.Add(article); + await context.SaveChangesAsync(); + + var route = $"/api/v1/articles/{article.Id}/relationships/tags"; + var request = new HttpRequestMessage(new HttpMethod("PATCH"), route); + var content = new + { + data = new [] { + new { + type = "tags", + id = tag.StringId + } + } + }; + + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + + // act + var response = await _fixture.Client.SendAsync(request); + + // assert + var body = await response.Content.ReadAsStringAsync(); + Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}"); + + _fixture.ReloadDbContext(); + var persistedArticle = await _fixture.Context.Articles + .Include(a => a.ArticleTags) + .SingleAsync(a => a.Id == article.Id); + + var persistedArticleTag = Assert.Single(persistedArticle.ArticleTags); + Assert.Equal(tag.Id, persistedArticleTag.TagId); + } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs index ce70fced84..6d6de345a0 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.TestHost; using JsonApiDotNetCore.Services; using JsonApiDotNetCore.Data; +using Microsoft.EntityFrameworkCore; namespace JsonApiDotNetCoreExampleTests.Acceptance { @@ -33,6 +34,11 @@ public TestFixture() public IJsonApiDeSerializer DeSerializer { get; private set; } public IJsonApiContext JsonApiContext { get; private set; } public T GetService() => (T)_services.GetService(typeof(T)); + + public void ReloadDbContext() + { + Context = new AppDbContext(GetService>()); + } private bool disposedValue = false; protected virtual void Dispose(bool disposing) diff --git a/test/UnitTests/Extensions/TypeExtensions_Tests.cs b/test/UnitTests/Extensions/TypeExtensions_Tests.cs index f59fa37be0..f565019f26 100644 --- a/test/UnitTests/Extensions/TypeExtensions_Tests.cs +++ b/test/UnitTests/Extensions/TypeExtensions_Tests.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Models; @@ -36,6 +37,32 @@ public void New_Creates_An_Instance_If_T_Implements_Interface() Assert.IsType(instance); } + [Fact] + public void Implements_Returns_True_If_Type_Implements_Interface() + { + // arrange + var type = typeof(Model); + + // act + var result = type.Implements(); + + // assert + Assert.True(result); + } + + [Fact] + public void Implements_Returns_False_If_Type_DoesNot_Implement_Interface() + { + // arrange + var type = typeof(String); + + // act + var result = type.Implements(); + + // assert + Assert.False(result); + } + private class Model : IIdentifiable { public string StringId { get; set; }