diff --git a/.gitignore b/.gitignore index b6767ff903..f3282bbc8c 100644 --- a/.gitignore +++ b/.gitignore @@ -234,3 +234,6 @@ _Pvt_Extensions # FAKE - F# Make .fake/ + +### Rider ### +.idea/ \ No newline at end of file diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Resources/CourseResource.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Resources/CourseResource.cs index cacd9e11d1..e981c70cc9 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Resources/CourseResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Resources/CourseResource.cs @@ -1,6 +1,7 @@ using JsonApiDotNetCore.Models; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using JsonApiDotNetCoreExample.Models.Entities; namespace JsonApiDotNetCoreExample.Models.Resources { @@ -17,7 +18,7 @@ public class CourseResource : Identifiable [Attr("description")] public string Description { get; set; } - [HasOne("department")] + [HasOne("department", mappedBy: "Department")] public DepartmentResource Department { get; set; } public int? DepartmentId { get; set; } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Resources/DepartmentResource.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Resources/DepartmentResource.cs index d9d6725b71..47648afcdf 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Resources/DepartmentResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Resources/DepartmentResource.cs @@ -8,7 +8,7 @@ public class DepartmentResource : Identifiable [Attr("name")] public string Name { get; set; } - [HasMany("courses")] + [HasMany("courses", mappedBy: "Courses")] public List Courses { get; set; } } } diff --git a/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs b/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs index 80ba077866..ca6caad1f8 100644 --- a/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs @@ -168,7 +168,7 @@ protected virtual List GetRelationships(Type entityType) attribute.Type = GetRelationshipType(attribute, prop); attributes.Add(attribute); - if(attribute is HasManyThroughAttribute hasManyThroughAttribute) { + 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}'."); @@ -211,13 +211,8 @@ protected virtual List GetRelationships(Type entityType) return attributes; } - protected virtual Type GetRelationshipType(RelationshipAttribute relation, PropertyInfo prop) - { - if (relation.IsHasMany) - return prop.PropertyType.GetGenericArguments()[0]; - else - return prop.PropertyType; - } + protected virtual Type GetRelationshipType(RelationshipAttribute relation, PropertyInfo prop) => + relation.IsHasMany ? prop.PropertyType.GetGenericArguments()[0] : prop.PropertyType; private Type GetResourceDefinitionType(Type entityType) => typeof(ResourceDefinition<>).MakeGenericType(entityType); diff --git a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs index b92667af95..fd3a8b48e8 100644 --- a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs +++ b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs @@ -155,7 +155,7 @@ public virtual async Task CreateAsync(TEntity entity) protected virtual void AttachRelationships(TEntity entity = null) { AttachHasManyPointers(entity); - AttachHasOnePointers(); + AttachHasOnePointers(entity); } /// @@ -163,16 +163,38 @@ public void DetachRelationshipPointers(TEntity entity) { foreach (var hasOneRelationship in _jsonApiContext.HasOneRelationshipPointers.Get()) { - _context.Entry(hasOneRelationship.Value).State = EntityState.Detached; + var hasOne = (HasOneAttribute) hasOneRelationship.Key; + if (hasOne.EntityPropertyName != null) + { + var relatedEntity = entity.GetType().GetProperty(hasOne.EntityPropertyName)?.GetValue(entity); + if (relatedEntity != null) + _context.Entry(relatedEntity).State = EntityState.Detached; + } + else + { + _context.Entry(hasOneRelationship.Value).State = EntityState.Detached; + } } foreach (var hasManyRelationship in _jsonApiContext.HasManyRelationshipPointers.Get()) { - foreach (var pointer in hasManyRelationship.Value) + var hasMany = (HasManyAttribute) hasManyRelationship.Key; + if (hasMany.EntityPropertyName != null) { - _context.Entry(pointer).State = EntityState.Detached; + var relatedList = (IList)entity.GetType().GetProperty(hasMany.EntityPropertyName)?.GetValue(entity); + foreach (var related in relatedList) + { + _context.Entry(related).State = EntityState.Detached; + } } - + else + { + foreach (var pointer in hasManyRelationship.Value) + { + _context.Entry(pointer).State = EntityState.Detached; + } + } + // HACK: detaching has many relationships doesn't appear to be sufficient // the navigation property actually needs to be nulled out, otherwise // EF adds duplicate instances to the collection @@ -192,14 +214,27 @@ private void AttachHasManyPointers(TEntity entity) if (relationship.Key is HasManyThroughAttribute hasManyThrough) AttachHasManyThrough(entity, hasManyThrough, relationship.Value); else - AttachHasMany(relationship.Key as HasManyAttribute, relationship.Value); + AttachHasMany(entity, relationship.Key as HasManyAttribute, relationship.Value); } } - private void AttachHasMany(HasManyAttribute relationship, IList pointers) + private void AttachHasMany(TEntity entity, HasManyAttribute relationship, IList pointers) { - foreach (var pointer in pointers) - _context.Entry(pointer).State = EntityState.Unchanged; + if (relationship.EntityPropertyName != null) + { + var relatedList = (IList)entity.GetType().GetProperty(relationship.EntityPropertyName)?.GetValue(entity); + foreach (var related in relatedList) + { + _context.Entry(related).State = EntityState.Unchanged; + } + } + else + { + foreach (var pointer in pointers) + { + _context.Entry(pointer).State = EntityState.Unchanged; + } + } } private void AttachHasManyThrough(TEntity entity, HasManyThroughAttribute hasManyThrough, IList pointers) @@ -227,12 +262,27 @@ private void AttachHasManyThrough(TEntity entity, HasManyThroughAttribute hasMan /// This is used to allow creation of HasOne relationships when the /// independent side of the relationship already exists. /// - private void AttachHasOnePointers() + private void AttachHasOnePointers(TEntity entity) { var relationships = _jsonApiContext.HasOneRelationshipPointers.Get(); foreach (var relationship in relationships) - if (_context.Entry(relationship.Value).State == EntityState.Detached && _context.EntityIsTracked(relationship.Value) == false) - _context.Entry(relationship.Value).State = EntityState.Unchanged; + { + if (relationship.Key.GetType() != typeof(HasOneAttribute)) + continue; + + var hasOne = (HasOneAttribute) relationship.Key; + if (hasOne.EntityPropertyName != null) + { + var relatedEntity = entity.GetType().GetProperty(hasOne.EntityPropertyName)?.GetValue(entity); + if (relatedEntity != null && _context.Entry(relatedEntity).State == EntityState.Detached && _context.EntityIsTracked((IIdentifiable)relatedEntity) == false) + _context.Entry(relatedEntity).State = EntityState.Unchanged; + } + else + { + if (_context.Entry(relationship.Value).State == EntityState.Detached && _context.EntityIsTracked(relationship.Value) == false) + _context.Entry(relationship.Value).State = EntityState.Unchanged; + } + } } /// diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index 183a7c8084..844effdda7 100644 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -1,6 +1,6 @@ - 3.0.0 + 3.0.1 $(NetStandardVersion) JsonApiDotNetCore JsonApiDotNetCore diff --git a/src/JsonApiDotNetCore/Models/HasManyAttribute.cs b/src/JsonApiDotNetCore/Models/HasManyAttribute.cs index 7dc4c9ae65..2d69b0e9c7 100644 --- a/src/JsonApiDotNetCore/Models/HasManyAttribute.cs +++ b/src/JsonApiDotNetCore/Models/HasManyAttribute.cs @@ -11,6 +11,7 @@ public class HasManyAttribute : RelationshipAttribute /// The relationship name as exposed by the API /// Which links are available. Defaults to /// Whether or not this relationship can be included using the ?include=public-name query string + /// The name of the entity mapped property, defaults to null /// /// /// @@ -23,8 +24,8 @@ public class HasManyAttribute : RelationshipAttribute /// /// /// - public HasManyAttribute(string publicName = null, Link documentLinks = Link.All, bool canInclude = true) - : base(publicName, documentLinks, canInclude) + public HasManyAttribute(string publicName = null, Link documentLinks = Link.All, bool canInclude = true, string mappedBy = null) + : base(publicName, documentLinks, canInclude, mappedBy) { } /// diff --git a/src/JsonApiDotNetCore/Models/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Models/HasManyThroughAttribute.cs index 49e18a43b3..c6f5bc47db 100644 --- a/src/JsonApiDotNetCore/Models/HasManyThroughAttribute.cs +++ b/src/JsonApiDotNetCore/Models/HasManyThroughAttribute.cs @@ -1,5 +1,6 @@ using System; using System.Reflection; +using System.Security; namespace JsonApiDotNetCore.Models { @@ -30,14 +31,15 @@ public class HasManyThroughAttribute : HasManyAttribute /// 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 + /// The name of the entity mapped property, defaults to null /// /// /// /// [HasManyThrough(nameof(ArticleTags), documentLinks: Link.All, canInclude: true)] /// /// - public HasManyThroughAttribute(string internalThroughName, Link documentLinks = Link.All, bool canInclude = true) - : base(null, documentLinks, canInclude) + public HasManyThroughAttribute(string internalThroughName, Link documentLinks = Link.All, bool canInclude = true, string mappedBy = null) + : base(null, documentLinks, canInclude, mappedBy) { InternalThroughName = internalThroughName; } @@ -50,14 +52,15 @@ public HasManyThroughAttribute(string internalThroughName, Link documentLinks = /// 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 + /// The name of the entity mapped property, defaults to null /// /// /// /// [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) + public HasManyThroughAttribute(string publicName, string internalThroughName, Link documentLinks = Link.All, bool canInclude = true, string mappedBy = null) + : base(publicName, documentLinks, canInclude, mappedBy) { InternalThroughName = internalThroughName; } @@ -161,4 +164,4 @@ public HasManyThroughAttribute(string publicName, string internalThroughName, Li /// 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 7e3cc6fb1a..45b08590b3 100644 --- a/src/JsonApiDotNetCore/Models/HasOneAttribute.cs +++ b/src/JsonApiDotNetCore/Models/HasOneAttribute.cs @@ -13,6 +13,7 @@ public class HasOneAttribute : RelationshipAttribute /// Which links are available. Defaults to /// Whether or not this relationship can be included using the ?include=public-name query string /// The foreign key property name. Defaults to "{RelationshipName}Id" + /// The name of the entity mapped property, defaults to null /// /// /// Using an alternative foreign key: @@ -20,21 +21,21 @@ public class HasOneAttribute : RelationshipAttribute /// /// public class Article : Identifiable /// { - /// [HasOne("author", withForiegnKey: nameof(AuthorKey)] + /// [HasOne("author", withForeignKey: nameof(AuthorKey)] /// public Author Author { get; set; } /// public int AuthorKey { get; set; } /// } /// /// /// - public HasOneAttribute(string publicName = null, Link documentLinks = Link.All, bool canInclude = true, string withForeignKey = null) - : base(publicName, documentLinks, canInclude) + public HasOneAttribute(string publicName = null, Link documentLinks = Link.All, bool canInclude = true, string withForeignKey = null, string mappedBy = null) + : base(publicName, documentLinks, canInclude, mappedBy) { _explicitIdentifiablePropertyName = withForeignKey; } private readonly string _explicitIdentifiablePropertyName; - + /// /// The independent resource identifier. /// diff --git a/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs b/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs index facb7780d9..bc6982f062 100644 --- a/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs @@ -6,15 +6,16 @@ namespace JsonApiDotNetCore.Models { public abstract class RelationshipAttribute : Attribute { - protected RelationshipAttribute(string publicName, Link documentLinks, bool canInclude) + protected RelationshipAttribute(string publicName, Link documentLinks, bool canInclude, string mappedBy) { PublicRelationshipName = publicName; DocumentLinks = documentLinks; CanInclude = canInclude; + EntityPropertyName = mappedBy; } public string PublicRelationshipName { get; internal set; } - public string InternalRelationshipName { get; internal set; } + public string InternalRelationshipName { get; internal set; } /// /// The related entity type. This does not necessarily match the navigation property type. @@ -31,6 +32,7 @@ protected RelationshipAttribute(string publicName, Link documentLinks, bool canI public bool IsHasOne => GetType() == typeof(HasOneAttribute); public Link DocumentLinks { get; } = Link.All; public bool CanInclude { get; } + public string EntityPropertyName { get; } public bool TryGetHasOne(out HasOneAttribute result) { @@ -55,10 +57,10 @@ public bool TryGetHasMany(out HasManyAttribute result) } public abstract void SetValue(object entity, object newValue); - + public object GetValue(object entity) => entity - ?.GetType() - .GetProperty(InternalRelationshipName) + ?.GetType()? + .GetProperty(InternalRelationshipName)? .GetValue(entity); public override string ToString() diff --git a/test/ResourceEntitySeparationExampleTests/Acceptance/AddTests.cs b/test/ResourceEntitySeparationExampleTests/Acceptance/AddTests.cs index 87e0a9d1ce..f50ad734b7 100644 --- a/test/ResourceEntitySeparationExampleTests/Acceptance/AddTests.cs +++ b/test/ResourceEntitySeparationExampleTests/Acceptance/AddTests.cs @@ -1,7 +1,9 @@ using JsonApiDotNetCoreExample.Models.Resources; using System.Collections.Generic; +using System.Linq; using System.Net; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization.Infrastructure; using Xunit; namespace ResourceEntitySeparationExampleTests.Acceptance @@ -46,6 +48,54 @@ public async Task Can_Create_Course() Assert.Equal(course.Title, data.Title); Assert.Equal(course.Description, data.Description); } + + [Fact] + public async Task Can_Create_Course_With_Department_Id() + { + // arrange + var route = $"/api/v1/courses/"; + var course = _fixture.CourseFaker.Generate(); + + var department = _fixture.DepartmentFaker.Generate(); + _fixture.Context.Departments.Add(department); + _fixture.Context.SaveChanges(); + + var content = new + { + data = new + { + type = "courses", + attributes = new Dictionary + { + { "number", course.Number }, + { "title", course.Title }, + { "description", course.Description } + }, + relationships = new + { + department = new + { + data = new + { + type = "departments", + id = department.Id + } + } + } + } + }; + + // act + var (response, data) = await _fixture.PostAsync(route, content); + + // assert + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.NotNull(data); + Assert.Equal(course.Number, data.Number); + Assert.Equal(course.Title, data.Title); + Assert.Equal(course.Description, data.Description); + Assert.Equal(department.Id, data.DepartmentId); + } [Fact] public async Task Can_Create_Department() @@ -73,6 +123,62 @@ public async Task Can_Create_Department() Assert.NotNull(data); Assert.Equal(dept.Name, data.Name); } + + [Fact] + public async Task Can_Create_Department_With_Courses() + { + // arrange + var route = $"/api/v1/departments/"; + var dept = _fixture.DepartmentFaker.Generate(); + + var one = _fixture.CourseFaker.Generate(); + var two = _fixture.CourseFaker.Generate(); + _fixture.Context.Courses.Add(one); + _fixture.Context.Courses.Add(two); + _fixture.Context.SaveChanges(); + + var content = new + { + data = new + { + type = "departments", + attributes = new Dictionary + { + { "name", dept.Name } + }, + relationships = new + { + courses = new + { + data = new[] + { + new + { + type = "courses", + id = one.Id + }, + new + { + type = "courses", + id = two.Id + } + } + } + } + } + }; + + // act + var (response, data) = await _fixture.PostAsync(route, content); + + // assert + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.NotNull(data); + Assert.Equal(dept.Name, data.Name); + Assert.NotEmpty(data.Courses); + Assert.NotNull(data.Courses.SingleOrDefault(c => c.Id == one.Id)); + Assert.NotNull(data.Courses.SingleOrDefault(c => c.Id == two.Id)); + } [Fact] public async Task Can_Create_Student()