diff --git a/JsonApiDotNetCore.sln b/JsonApiDotNetCore.sln index 3d697e1ee5..3d1fcaa179 100644 --- a/JsonApiDotNetCore.sln +++ b/JsonApiDotNetCore.sln @@ -18,6 +18,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples", "Examples", "{02 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", "{076E1AE4-FD25-4684-B826-CAAE37FEA0AA}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GettingStarted", "src\Examples\GettingStarted\GettingStarted.csproj", "{067FFD7A-C66B-473D-8471-37F5C95DF61C}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JsonApiDotNetCoreExampleTests", "test\JsonApiDotNetCoreExampleTests\JsonApiDotNetCoreExampleTests.csproj", "{CAF331F8-9255-4D72-A1A8-A54141E99F1E}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NoEntityFrameworkTests", "test\NoEntityFrameworkTests\NoEntityFrameworkTests.csproj", "{4F15A8F8-5BC6-45A1-BC51-03F921B726A4}" @@ -36,8 +38,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ReportsExample", "src\Examp EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JsonApiDotNetCore", "src\JsonApiDotNetCore\JsonApiDotNetCore.csproj", "{21D27239-138D-4604-8E49-DCBE41BCE4C8}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GettingStarted", "src\Examples\GettingStarted\GettingStarted.csproj", "{067FFD7A-C66B-473D-8471-37F5C95DF61C}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MultiDbContextExample", "src\Examples\MultiDbContextExample\MultiDbContextExample.csproj", "{6CAFDDBE-00AB-4784-801B-AB419C3C3A26}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MultiDbContextTests", "test\MultiDbContextTests\MultiDbContextTests.csproj", "{EC3202C6-1D4C-4B14-A599-B9D3F27FE3BA}" diff --git a/docs/usage/resources/relationships.md b/docs/usage/resources/relationships.md index 2d515872e9..869b38f97c 100644 --- a/docs/usage/resources/relationships.md +++ b/docs/usage/resources/relationships.md @@ -20,7 +20,6 @@ public class TodoItem : Identifiable The left side of this relationship is of type `TodoItem` (public name: "todoItems") and the right side is of type `Person` (public name: "persons"). - ## HasMany This exposes a to-many relationship. @@ -35,32 +34,6 @@ public class Person : Identifiable The left side of this relationship is of type `Person` (public name: "persons") and the right side is of type `TodoItem` (public name: "todoItems"). - -## HasManyThrough - -Earlier versions of Entity Framework Core (up to v5) [did not support](https://github.com/aspnet/EntityFrameworkCore/issues/1368) many-to-many relationships without a join entity. -For this reason, we have decided to fill this gap by allowing applications to declare a relationship as `HasManyThrough`. -JsonApiDotNetCore will expose this relationship to the client the same way as any other `HasMany` relationship. -However, under the covers it will use the join type and Entity Framework Core's APIs to get and set the relationship. - -```c# -public class Article : Identifiable -{ - // tells Entity Framework Core to ignore this property - [NotMapped] - - // tells JsonApiDotNetCore to use the join table below - [HasManyThrough(nameof(ArticleTags))] - public ICollection Tags { get; set; } - - // this is the Entity Framework Core navigation to the join table - public ICollection ArticleTags { get; set; } -} -``` - -The left side of this relationship is of type `Article` (public name: "articles") and the right side is of type `Tag` (public name: "tags"). - - ## Name There are two ways the exposed relationship name is determined: diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index 9cb18cf548..cc59628fc6 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -18,13 +18,6 @@ public AppDbContext(DbContextOptions options) protected override void OnModelCreating(ModelBuilder builder) { - builder.Entity() - .HasKey(todoItemTag => new - { - todoItemTag.TodoItemId, - todoItemTag.TagId - }); - // When deleting a person, un-assign him/her from existing todo items. builder.Entity() .HasMany(person => person.AssignedTodoItems) diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs index cd6e73552f..e0f5d0894c 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; @@ -12,5 +13,8 @@ public sealed class Tag : Identifiable [MinLength(1)] [Attr] public string Name { get; set; } + + [HasMany] + public ISet TodoItems { get; set; } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs index 95643e61a3..dbc0c59a04 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.ComponentModel.DataAnnotations.Schema; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -28,10 +27,7 @@ public sealed class TodoItem : Identifiable [HasOne] public Person Assignee { get; set; } - [NotMapped] - [HasManyThrough(nameof(TodoItemTags))] + [HasMany] public ISet Tags { get; set; } - - public ISet TodoItemTags { get; set; } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemTag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemTag.cs deleted file mode 100644 index 8d1ef42d42..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemTag.cs +++ /dev/null @@ -1,14 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCoreExample.Models -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class TodoItemTag - { - public int TodoItemId { get; set; } - public TodoItem TodoItem { get; set; } - - public int TagId { get; set; } - public Tag Tag { get; set; } - } -} diff --git a/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs index acbfc5961e..3629b1b69a 100644 --- a/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs @@ -18,67 +18,38 @@ public interface IResourceGraph : IResourceContextProvider /// all exposed fields are returned. /// /// - /// The resource for which to retrieve fields. + /// The resource type for which to retrieve fields. /// /// - /// Should be of the form: (TResource e) => new { e.Field1, e.Field2 } + /// Should be of the form: (TResource r) => new { r.Field1, r.Field2 } /// - IReadOnlyCollection GetFields(Expression> selector = null) + IReadOnlyCollection GetFields(Expression> selector) where TResource : class, IIdentifiable; /// - /// Gets all attributes for that are targeted by the selector. If no selector is provided, all exposed fields are + /// Gets all attributes for that are targeted by the selector. If no selector is provided, all exposed attributes are /// returned. /// /// - /// The resource for which to retrieve attributes. + /// The resource type for which to retrieve attributes. /// /// - /// Should be of the form: (TResource e) => new { e.Attribute1, e.Attribute2 } + /// Should be of the form: (TResource r) => new { r.Attribute1, r.Attribute2 } /// - IReadOnlyCollection GetAttributes(Expression> selector = null) + IReadOnlyCollection GetAttributes(Expression> selector) where TResource : class, IIdentifiable; /// - /// Gets all relationships for that are targeted by the selector. If no selector is provided, all exposed fields are - /// returned. + /// Gets all relationships for that are targeted by the selector. If no selector is provided, all exposed relationships + /// are returned. /// /// - /// The resource for which to retrieve relationships. + /// The resource type for which to retrieve relationships. /// /// - /// Should be of the form: (TResource e) => new { e.Relationship1, e.Relationship2 } + /// Should be of the form: (TResource r) => new { r.Relationship1, r.Relationship2 } /// - IReadOnlyCollection GetRelationships(Expression> selector = null) + IReadOnlyCollection GetRelationships(Expression> selector) where TResource : class, IIdentifiable; - - /// - /// Gets all exposed fields (attributes and relationships) for the specified type. - /// - /// - /// The resource type. Must implement . - /// - IReadOnlyCollection GetFields(Type type); - - /// - /// Gets all exposed attributes for the specified type. - /// - /// - /// The resource type. Must implement . - /// - IReadOnlyCollection GetAttributes(Type type); - - /// - /// Gets all exposed relationships for the specified type. - /// - /// - /// The resource type. Must implement . - /// - IReadOnlyCollection GetRelationships(Type type); - - /// - /// Traverses the resource graph, looking for the inverse relationship of the specified . - /// - RelationshipAttribute GetInverseRelationship(RelationshipAttribute relationship); } } diff --git a/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs b/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs index 749abfde2d..27a70d314c 100644 --- a/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs +++ b/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using JetBrains.Annotations; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources.Annotations; @@ -9,7 +10,7 @@ namespace JsonApiDotNetCore.Configuration { /// [PublicAPI] - public class InverseNavigationResolver : IInverseNavigationResolver + public sealed class InverseNavigationResolver : IInverseNavigationResolver { private readonly IResourceContextProvider _resourceContextProvider; private readonly IEnumerable _dbContextResolvers; @@ -35,25 +36,37 @@ public void Resolve() private void Resolve(DbContext dbContext) { - foreach (ResourceContext resourceContext in _resourceContextProvider.GetResourceContexts()) + foreach (ResourceContext resourceContext in _resourceContextProvider.GetResourceContexts().Where(context => context.Relationships.Any())) { IEntityType entityType = dbContext.Model.FindEntityType(resourceContext.ResourceType); if (entityType != null) { - ResolveRelationships(resourceContext.Relationships, entityType); + IDictionary navigationMap = GetNavigations(entityType); + ResolveRelationships(resourceContext.Relationships, navigationMap); } } } - private void ResolveRelationships(IReadOnlyCollection relationships, IEntityType entityType) + private static IDictionary GetNavigations(IEntityType entityType) + { + // @formatter:wrap_chained_method_calls chop_always + + return entityType.GetNavigations() + .Cast() + .Concat(entityType.GetSkipNavigations()) + .ToDictionary(navigation => navigation.Name); + + // @formatter:wrap_chained_method_calls restore + } + + private void ResolveRelationships(IReadOnlyCollection relationships, IDictionary navigationMap) { foreach (RelationshipAttribute relationship in relationships) { - if (!(relationship is HasManyThroughAttribute)) + if (navigationMap.TryGetValue(relationship.Property.Name, out INavigationBase navigation)) { - INavigation inverseNavigation = entityType.FindNavigation(relationship.Property.Name)?.Inverse; - relationship.InverseNavigationProperty = inverseNavigation?.PropertyInfo; + relationship.InverseNavigationProperty = navigation.Inverse?.PropertyInfo; } } } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 1efd2c13ee..129b30d11c 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -18,6 +18,7 @@ using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Internal; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; @@ -51,7 +52,7 @@ public JsonApiApplicationBuilder(IServiceCollection services, IMvcCoreBuilder mv var loggerFactory = _intermediateProvider.GetRequiredService(); _resourceGraphBuilder = new ResourceGraphBuilder(_options, loggerFactory); - _serviceDiscoveryFacade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, loggerFactory); + _serviceDiscoveryFacade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, loggerFactory); } /// @@ -283,7 +284,12 @@ private void AddResourcesFromDbContext(DbContext dbContext, ResourceGraphBuilder { foreach (IEntityType entityType in dbContext.Model.GetEntityTypes()) { - builder.Add(entityType.ClrType); +#pragma warning disable EF1001 // Internal EF Core API usage. + if (entityType is not EntityType { IsImplicitlyCreatedJoinEntityType: true }) +#pragma warning restore EF1001 // Internal EF Core API usage. + { + builder.Add(entityType.ClrType); + } } } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs index 1fc36ca4a2..14cb0547cd 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs @@ -11,7 +11,7 @@ namespace JsonApiDotNetCore.Configuration { /// [PublicAPI] - public class ResourceGraph : IResourceGraph + public sealed class ResourceGraph : IResourceGraph { private static readonly Type ProxyTargetAccessorType = Type.GetType("Castle.DynamicProxy.IProxyTargetAccessor, Castle.Core"); private readonly IReadOnlyCollection _resources; @@ -47,147 +47,110 @@ public ResourceContext GetResourceContext(Type resourceType) : _resources.SingleOrDefault(resourceContext => resourceContext.ResourceType == resourceType); } - /// - public ResourceContext GetResourceContext() - where TResource : class, IIdentifiable + private bool IsLazyLoadingProxyForResourceType(Type resourceType) { - return GetResourceContext(typeof(TResource)); + return ProxyTargetAccessorType?.IsAssignableFrom(resourceType) ?? false; } /// - public IReadOnlyCollection GetFields(Expression> selector = null) + public ResourceContext GetResourceContext() where TResource : class, IIdentifiable { - return Getter(selector); + return GetResourceContext(typeof(TResource)); } /// - public IReadOnlyCollection GetAttributes(Expression> selector = null) + public IReadOnlyCollection GetFields(Expression> selector) where TResource : class, IIdentifiable { - return Getter(selector, FieldFilterType.Attribute).Cast().ToArray(); - } + ArgumentGuard.NotNull(selector, nameof(selector)); - /// - public IReadOnlyCollection GetRelationships(Expression> selector = null) - where TResource : class, IIdentifiable - { - return Getter(selector, FieldFilterType.Relationship).Cast().ToArray(); + return FilterFields(selector); } /// - public IReadOnlyCollection GetFields(Type type) + public IReadOnlyCollection GetAttributes(Expression> selector) + where TResource : class, IIdentifiable { - ArgumentGuard.NotNull(type, nameof(type)); + ArgumentGuard.NotNull(selector, nameof(selector)); - return GetResourceContext(type).Fields; + return FilterFields(selector); } /// - public IReadOnlyCollection GetAttributes(Type type) + public IReadOnlyCollection GetRelationships(Expression> selector) + where TResource : class, IIdentifiable { - ArgumentGuard.NotNull(type, nameof(type)); + ArgumentGuard.NotNull(selector, nameof(selector)); - return GetResourceContext(type).Attributes; + return FilterFields(selector); } - /// - public IReadOnlyCollection GetRelationships(Type type) + private IReadOnlyCollection FilterFields(Expression> selector) + where TResource : class, IIdentifiable + where TField : ResourceFieldAttribute { - ArgumentGuard.NotNull(type, nameof(type)); + IReadOnlyCollection source = GetFieldsOfType(); + var matches = new List(); - return GetResourceContext(type).Relationships; - } + foreach (string memberName in ToMemberNames(selector)) + { + TField matchingField = source.FirstOrDefault(field => field.Property.Name == memberName); - /// - public RelationshipAttribute GetInverseRelationship(RelationshipAttribute relationship) - { - ArgumentGuard.NotNull(relationship, nameof(relationship)); + if (matchingField == null) + { + throw new ArgumentException($"Member '{memberName}' is not exposed as a JSON:API field."); + } - if (relationship.InverseNavigationProperty == null) - { - return null; + matches.Add(matchingField); } - return GetResourceContext(relationship.RightType).Relationships - .SingleOrDefault(nextRelationship => nextRelationship.Property == relationship.InverseNavigationProperty); + return matches; } - private IReadOnlyCollection Getter(Expression> selector = null, - FieldFilterType type = FieldFilterType.None) - where TResource : class, IIdentifiable + private IReadOnlyCollection GetFieldsOfType() + where TKind : ResourceFieldAttribute { - IReadOnlyCollection available; + ResourceContext resourceContext = GetResourceContext(typeof(TResource)); - if (type == FieldFilterType.Attribute) - { - available = GetResourceContext(typeof(TResource)).Attributes; - } - else if (type == FieldFilterType.Relationship) - { - available = GetResourceContext(typeof(TResource)).Relationships; - } - else + if (typeof(TKind) == typeof(AttrAttribute)) { - available = GetResourceContext(typeof(TResource)).Fields; + return (IReadOnlyCollection)resourceContext.Attributes; } - if (selector == null) + if (typeof(TKind) == typeof(RelationshipAttribute)) { - return available; + return (IReadOnlyCollection)resourceContext.Relationships; } - var targeted = new List(); + return (IReadOnlyCollection)resourceContext.Fields; + } + private IEnumerable ToMemberNames(Expression> selector) + { Expression selectorBody = RemoveConvert(selector.Body); if (selectorBody is MemberExpression memberExpression) { // model => model.Field1 - try - { - targeted.Add(available.Single(field => field.Property.Name == memberExpression.Member.Name)); - return targeted; - } - catch (InvalidOperationException) - { - ThrowNotExposedError(memberExpression.Member.Name, type); - } - } - if (selectorBody is NewExpression newExpression) + yield return memberExpression.Member.Name; + } + else if (selectorBody is NewExpression newExpression) { // model => new { model.Field1, model.Field2 } - string memberName = null; - try + foreach (MemberInfo member in newExpression.Members ?? Enumerable.Empty()) { - if (newExpression.Members == null) - { - return targeted; - } - - foreach (MemberInfo member in newExpression.Members) - { - memberName = member.Name; - targeted.Add(available.Single(field => field.Property.Name == memberName)); - } - - return targeted; - } - catch (InvalidOperationException) - { - ThrowNotExposedError(memberName, type); + yield return member.Name; } } - - throw new ArgumentException($"The expression '{selector}' should select a single property or select multiple properties into an anonymous type. " + - "For example: 'article => article.Title' or 'article => new { article.Title, article.PageCount }'."); - } - - private bool IsLazyLoadingProxyForResourceType(Type resourceType) - { - return ProxyTargetAccessorType?.IsAssignableFrom(resourceType) ?? false; + else + { + throw new ArgumentException( + $"The expression '{selector}' should select a single property or select multiple properties into an anonymous type. " + + "For example: 'article => article.Title' or 'article => new { article.Title, article.PageCount }'."); + } } private static Expression RemoveConvert(Expression expression) @@ -206,17 +169,5 @@ private static Expression RemoveConvert(Expression expression) } } } - - private void ThrowNotExposedError(string memberName, FieldFilterType type) - { - throw new ArgumentException($"{memberName} is not a JSON:API exposed {type:g}."); - } - - private enum FieldFilterType - { - None, - Attribute, - Relationship - } } } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index 6b757d82c5..64eca6dc41 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -3,7 +3,6 @@ using System.Linq; using System.Reflection; using JetBrains.Annotations; -using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.Logging; @@ -185,119 +184,24 @@ private IReadOnlyCollection GetRelationships(Type resourc var attributes = new List(); PropertyInfo[] properties = resourceType.GetProperties(); - foreach (PropertyInfo prop in properties) + foreach (PropertyInfo property in properties) { - var attribute = (RelationshipAttribute)prop.GetCustomAttribute(typeof(RelationshipAttribute)); + var attribute = (RelationshipAttribute)property.GetCustomAttribute(typeof(RelationshipAttribute)); - if (attribute == null) + if (attribute != null) { - continue; - } - - attribute.Property = prop; - attribute.PublicName ??= FormatPropertyName(prop); - attribute.RightType = GetRelationshipType(attribute, prop); - attribute.LeftType = resourceType; - attributes.Add(attribute); - - if (attribute is HasManyThroughAttribute hasManyThroughAttribute) - { - PropertyInfo throughProperty = properties.SingleOrDefault(property => property.Name == hasManyThroughAttribute.ThroughPropertyName); - - if (throughProperty == null) - { - throw new InvalidConfigurationException($"Invalid {nameof(HasManyThroughAttribute)} on '{resourceType}.{attribute.Property.Name}': " + - $"Resource does not contain a property named '{hasManyThroughAttribute.ThroughPropertyName}'."); - } - - Type throughType = TryGetThroughType(throughProperty); - - if (throughType == null) - { - throw new InvalidConfigurationException($"Invalid {nameof(HasManyThroughAttribute)} on '{resourceType}.{attribute.Property.Name}': " + - $"Referenced property '{throughProperty.Name}' does not implement 'ICollection'."); - } - - // ICollection - hasManyThroughAttribute.ThroughProperty = throughProperty; - - // ArticleTag - hasManyThroughAttribute.ThroughType = throughType; - - PropertyInfo[] throughProperties = throughType.GetProperties(); - - // ArticleTag.Article - if (hasManyThroughAttribute.LeftPropertyName != null) - { - // In case of a self-referencing many-to-many relationship, the left property name must be specified. - hasManyThroughAttribute.LeftProperty = hasManyThroughAttribute.ThroughType.GetProperty(hasManyThroughAttribute.LeftPropertyName) ?? - throw new InvalidConfigurationException( - $"'{throughType}' does not contain a navigation property named '{hasManyThroughAttribute.LeftPropertyName}'."); - } - else - { - // In case of a non-self-referencing many-to-many relationship, we just pick the single compatible type. - hasManyThroughAttribute.LeftProperty = - throughProperties.SingleOrDefault(property => property.PropertyType.IsAssignableFrom(resourceType)) ?? - throw new InvalidConfigurationException($"'{throughType}' does not contain a navigation property to type '{resourceType}'."); - } - - // ArticleTag.ArticleId - string leftIdPropertyName = hasManyThroughAttribute.LeftIdPropertyName ?? hasManyThroughAttribute.LeftProperty.Name + "Id"; + attribute.Property = property; + attribute.PublicName ??= FormatPropertyName(property); + attribute.LeftType = resourceType; + attribute.RightType = GetRelationshipType(attribute, property); - hasManyThroughAttribute.LeftIdProperty = throughProperties.SingleOrDefault(property => property.Name == leftIdPropertyName) ?? - throw new InvalidConfigurationException( - $"'{throughType}' does not contain a relationship ID property to type '{resourceType}' with name '{leftIdPropertyName}'."); - - // ArticleTag.Tag - if (hasManyThroughAttribute.RightPropertyName != null) - { - // In case of a self-referencing many-to-many relationship, the right property name must be specified. - hasManyThroughAttribute.RightProperty = hasManyThroughAttribute.ThroughType.GetProperty(hasManyThroughAttribute.RightPropertyName) ?? - throw new InvalidConfigurationException( - $"'{throughType}' does not contain a navigation property named '{hasManyThroughAttribute.RightPropertyName}'."); - } - else - { - // In case of a non-self-referencing many-to-many relationship, we just pick the single compatible type. - hasManyThroughAttribute.RightProperty = - throughProperties.SingleOrDefault(property => property.PropertyType == hasManyThroughAttribute.RightType) ?? - throw new InvalidConfigurationException( - $"'{throughType}' does not contain a navigation property to type '{hasManyThroughAttribute.RightType}'."); - } - - // ArticleTag.TagId - string rightIdPropertyName = hasManyThroughAttribute.RightIdPropertyName ?? hasManyThroughAttribute.RightProperty.Name + "Id"; - - hasManyThroughAttribute.RightIdProperty = throughProperties.SingleOrDefault(property => property.Name == rightIdPropertyName) ?? - throw new InvalidConfigurationException( - $"'{throughType}' does not contain a relationship ID property to type '{hasManyThroughAttribute.RightType}' with name '{rightIdPropertyName}'."); + attributes.Add(attribute); } } return attributes; } - private Type TryGetThroughType(PropertyInfo throughProperty) - { - if (throughProperty.PropertyType.IsGenericType) - { - Type[] typeArguments = throughProperty.PropertyType.GetGenericArguments(); - - if (typeArguments.Length == 1) - { - Type constructedThroughType = typeof(ICollection<>).MakeGenericType(typeArguments[0]); - - if (throughProperty.PropertyType.IsOrImplementsInterface(constructedThroughType)) - { - return typeArguments[0]; - } - } - } - - return null; - } - private Type GetRelationshipType(RelationshipAttribute relationship, PropertyInfo property) { ArgumentGuard.NotNull(relationship, nameof(relationship)); diff --git a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs index bc4cd6b4d0..7a179e5784 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs @@ -15,7 +15,7 @@ namespace JsonApiDotNetCore.Configuration /// Scans for types like resources, services, repositories and resource definitions in an assembly and registers them to the IoC container. /// [PublicAPI] - public class ServiceDiscoveryFacade + public sealed class ServiceDiscoveryFacade { internal static readonly HashSet ServiceInterfaces = new() { @@ -66,22 +66,18 @@ public class ServiceDiscoveryFacade private readonly ILogger _logger; private readonly IServiceCollection _services; private readonly ResourceGraphBuilder _resourceGraphBuilder; - private readonly IJsonApiOptions _options; private readonly ResourceDescriptorAssemblyCache _assemblyCache = new(); private readonly TypeLocator _typeLocator = new(); - public ServiceDiscoveryFacade(IServiceCollection services, ResourceGraphBuilder resourceGraphBuilder, IJsonApiOptions options, - ILoggerFactory loggerFactory) + public ServiceDiscoveryFacade(IServiceCollection services, ResourceGraphBuilder resourceGraphBuilder, ILoggerFactory loggerFactory) { ArgumentGuard.NotNull(services, nameof(services)); ArgumentGuard.NotNull(resourceGraphBuilder, nameof(resourceGraphBuilder)); ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); - ArgumentGuard.NotNull(options, nameof(options)); _logger = loggerFactory.CreateLogger(); _services = services; _resourceGraphBuilder = resourceGraphBuilder; - _options = options; } /// diff --git a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs index de2e9deecb..7f4f3ee004 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs @@ -17,7 +17,7 @@ namespace JsonApiDotNetCore.Errors /// The error that is thrown when model state validation fails. /// [PublicAPI] - public class InvalidModelStateException : JsonApiException + public sealed class InvalidModelStateException : JsonApiException { public InvalidModelStateException(ModelStateDictionary modelState, Type resourceType, bool includeExceptionStackTraceInErrors, NamingStrategy namingStrategy) diff --git a/src/JsonApiDotNetCore/Middleware/AsyncJsonApiExceptionFilter.cs b/src/JsonApiDotNetCore/Middleware/AsyncJsonApiExceptionFilter.cs index 54f9bfaf9f..72d635b5f3 100644 --- a/src/JsonApiDotNetCore/Middleware/AsyncJsonApiExceptionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/AsyncJsonApiExceptionFilter.cs @@ -8,7 +8,7 @@ namespace JsonApiDotNetCore.Middleware { /// [PublicAPI] - public class AsyncJsonApiExceptionFilter : IAsyncJsonApiExceptionFilter + public sealed class AsyncJsonApiExceptionFilter : IAsyncJsonApiExceptionFilter { private readonly IExceptionHandler _exceptionHandler; diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs index 156510298d..51a831a5b6 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs @@ -29,7 +29,7 @@ namespace JsonApiDotNetCore.Middleware /// public class SomeVeryCustomController : CoreJsonApiController { } // => /someVeryCustoms/relationship/relatedResource /// ]]> [PublicAPI] - public class JsonApiRoutingConvention : IJsonApiRoutingConvention + public sealed class JsonApiRoutingConvention : IJsonApiRoutingConvention { private readonly IJsonApiOptions _options; private readonly IResourceContextProvider _resourceContextProvider; diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs index fdcec72778..7f90e334ad 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs @@ -60,7 +60,7 @@ private Expression ProcessRelationshipChain(ResourceFieldChainExpression chain, foreach (RelationshipAttribute relationship in chain.Fields.Cast()) { - path = path == null ? relationship.RelationshipPath : path + "." + relationship.RelationshipPath; + path = path == null ? relationship.Property.Name : path + "." + relationship.Property.Name; ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(relationship.RightType); result = ApplyEagerLoads(result, resourceContext.EagerLoads, path); diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs index 208c1a9fb3..8fb8b96b2f 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs @@ -1,7 +1,6 @@ using System; using System.Linq.Expressions; using JetBrains.Annotations; -using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding { @@ -15,9 +14,8 @@ public sealed class LambdaScope : IDisposable public ParameterExpression Parameter { get; } public Expression Accessor { get; } - public HasManyThroughAttribute HasManyThrough { get; } - public LambdaScope(LambdaParameterNameFactory nameFactory, Type elementType, Expression accessorExpression, HasManyThroughAttribute hasManyThrough) + public LambdaScope(LambdaParameterNameFactory nameFactory, Type elementType, Expression accessorExpression) { ArgumentGuard.NotNull(nameFactory, nameof(nameFactory)); ArgumentGuard.NotNull(elementType, nameof(elementType)); @@ -25,20 +23,7 @@ public LambdaScope(LambdaParameterNameFactory nameFactory, Type elementType, Exp _parameterNameScope = nameFactory.Create(elementType.Name); Parameter = Expression.Parameter(elementType, _parameterNameScope.Name); - if (accessorExpression != null) - { - Accessor = accessorExpression; - } - else if (hasManyThrough != null) - { - Accessor = Expression.Property(Parameter, hasManyThrough.RightProperty); - } - else - { - Accessor = Parameter; - } - - HasManyThrough = hasManyThrough; + Accessor = accessorExpression ?? Parameter; } public void Dispose() diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs index d9dc5b6a19..26e8059ca8 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs @@ -1,7 +1,6 @@ using System; using System.Linq.Expressions; using JetBrains.Annotations; -using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding { @@ -9,21 +8,19 @@ namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding public sealed class LambdaScopeFactory { private readonly LambdaParameterNameFactory _nameFactory; - private readonly HasManyThroughAttribute _hasManyThrough; - public LambdaScopeFactory(LambdaParameterNameFactory nameFactory, HasManyThroughAttribute hasManyThrough = null) + public LambdaScopeFactory(LambdaParameterNameFactory nameFactory) { ArgumentGuard.NotNull(nameFactory, nameof(nameFactory)); _nameFactory = nameFactory; - _hasManyThrough = hasManyThrough; } public LambdaScope CreateScope(Type elementType, Expression accessorExpression = null) { ArgumentGuard.NotNull(elementType, nameof(elementType)); - return new LambdaScope(_nameFactory, elementType, accessorExpression, _hasManyThrough); + return new LambdaScope(_nameFactory, elementType, accessorExpression); } } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs index 543a89d673..bdbff2bb19 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs @@ -1,10 +1,8 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; using JetBrains.Annotations; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding { @@ -73,17 +71,5 @@ private Expression ExtensionMethodCall(Expression source, string operationName, Type[] typeArguments = ArrayFactory.Create(LambdaScope.Parameter.Type, keyType); return Expression.Call(_extensionType, operationName, typeArguments, source, keySelector); } - - protected override MemberExpression CreatePropertyExpressionForFieldChain(IReadOnlyCollection chain, Expression source) - { - string[] components = chain.Select(GetPropertyName).ToArray(); - return CreatePropertyExpressionFromComponents(LambdaScope.Accessor, components); - } - - private static string GetPropertyName(ResourceFieldAttribute field) - { - // In case of a HasManyThrough access (from count() function), we only need to look at the number of entries in the join table. - return field is HasManyThroughAttribute hasManyThrough ? hasManyThrough.ThroughProperty.Name : field.Property.Name; - } } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs index e8a8b8a6da..4d396dfbcc 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs @@ -61,18 +61,13 @@ private static Expression TryGetCollectionCount(Expression collectionExpression) public override Expression VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument) { - return CreatePropertyExpressionForFieldChain(expression.Fields, LambdaScope.Accessor); - } - - protected virtual MemberExpression CreatePropertyExpressionForFieldChain(IReadOnlyCollection chain, Expression source) - { - string[] components = chain.Select(field => field is RelationshipAttribute relationship ? relationship.RelationshipPath : field.Property.Name) - .ToArray(); + string[] components = expression.Fields + .Select(field => field is RelationshipAttribute relationship ? relationship.Property.Name : field.Property.Name).ToArray(); - return CreatePropertyExpressionFromComponents(source, components); + return CreatePropertyExpressionFromComponents(LambdaScope.Accessor, components); } - protected static MemberExpression CreatePropertyExpressionFromComponents(Expression source, IReadOnlyCollection components) + private static MemberExpression CreatePropertyExpressionFromComponents(Expression source, IReadOnlyCollection components) { MemberExpression property = null; diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs index a9c229425c..d8fb26f15a 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs @@ -77,14 +77,6 @@ private Expression CreateLambdaBodyInitializer(IDictionary ToPropertySelectors(IDictionary selector.Key is RelationshipAttribute); - foreach (KeyValuePair fieldSelector in resourceFieldSelectors) + foreach ((ResourceFieldAttribute resourceField, QueryLayer queryLayer) in resourceFieldSelectors) { - var propertySelector = new PropertySelector(fieldSelector.Key, fieldSelector.Value); + var propertySelector = new PropertySelector(resourceField.Property, queryLayer); if (propertySelector.Property.SetMethod != null) { @@ -155,8 +147,7 @@ private MemberAssignment CreatePropertyAssignment(PropertySelector selector, Lam if (selector.NextLayer != null) { - var hasManyThrough = selector.OriginatingField as HasManyThroughAttribute; - var lambdaScopeFactory = new LambdaScopeFactory(_nameFactory, hasManyThrough); + var lambdaScopeFactory = new LambdaScopeFactory(_nameFactory); assignmentRightHandSide = CreateAssignmentRightHandSideForLayer(selector.NextLayer, lambdaScope, propertyAccess, selector.Property, lambdaScopeFactory); @@ -238,7 +229,6 @@ private Expression SelectExtensionMethodCall(Expression source, Type elementType private sealed class PropertySelector { public PropertyInfo Property { get; } - public ResourceFieldAttribute OriginatingField { get; } public QueryLayer NextLayer { get; } public PropertySelector(PropertyInfo property, QueryLayer nextLayer = null) @@ -249,15 +239,6 @@ public PropertySelector(PropertyInfo property, QueryLayer nextLayer = null) NextLayer = nextLayer; } - public PropertySelector(ResourceFieldAttribute field, QueryLayer nextLayer = null) - { - ArgumentGuard.NotNull(field, nameof(field)); - - OriginatingField = field; - NextLayer = nextLayer; - Property = field is HasManyThroughAttribute hasManyThrough ? hasManyThrough.ThroughProperty : field.Property; - } - public override string ToString() { return "Property: " + (NextLayer != null ? Property.Name + "..." : Property.Name); diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs index 42fcfe8e23..d95d228c25 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs @@ -6,7 +6,6 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Resources.Internal; namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding @@ -72,8 +71,7 @@ public override Expression VisitCollectionNotEmpty(CollectionNotEmptyExpression if (expression.Filter != null) { - var hasManyThrough = expression.TargetCollection.Fields.Last() as HasManyThroughAttribute; - var lambdaScopeFactory = new LambdaScopeFactory(_nameFactory, hasManyThrough); + var lambdaScopeFactory = new LambdaScopeFactory(_nameFactory); using LambdaScope lambdaScope = lambdaScopeFactory.CreateScope(elementType); var builder = new WhereClauseBuilder(property, lambdaScope, typeof(Enumerable), _nameFactory); @@ -292,16 +290,5 @@ private static object ConvertTextToTargetType(string text, Type targetType) throw new InvalidQueryException("Query creation failed due to incompatible types.", exception); } } - - protected override MemberExpression CreatePropertyExpressionForFieldChain(IReadOnlyCollection chain, Expression source) - { - string[] components = chain.Select(GetPropertyName).ToArray(); - return CreatePropertyExpressionFromComponents(LambdaScope.Accessor, components); - } - - private static string GetPropertyName(ResourceFieldAttribute field) - { - return field is HasManyThroughAttribute hasManyThrough ? hasManyThrough.ThroughProperty.Name : field.Property.Name; - } } } diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs index a6d95560a2..4610beb0e6 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; @@ -58,12 +57,7 @@ public static void ResetChangeTracker(this DbContext dbContext) { ArgumentGuard.NotNull(dbContext, nameof(dbContext)); - List entriesWithChanges = dbContext.ChangeTracker.Entries().ToList(); - - foreach (EntityEntry entry in entriesWithChanges) - { - entry.State = EntityState.Detached; - } + dbContext.ChangeTracker.Clear(); } } } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 47ff3f9766..927665c965 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -265,7 +265,7 @@ protected void AssertIsNotClearingRequiredRelationship(RelationshipAttribute rel { bool relationshipIsRequired = false; - if (!(relationship is HasManyThroughAttribute)) + if (relationship is not HasManyAttribute { IsManyToMany: true }) { INavigation navigation = TryGetNavigation(relationship); relationshipIsRequired = navigation?.ForeignKey?.IsRequired ?? false; @@ -314,7 +314,7 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke using var collector = new PlaceholderResourceCollector(_resourceFactory, _dbContext); TResource resource = collector.CreateForId(id); - foreach (RelationshipAttribute relationship in _resourceGraph.GetRelationships()) + foreach (RelationshipAttribute relationship in _resourceGraph.GetResourceContext().Relationships) { // Loads the data of the relationship, if in EF Core it is configured in such a way that loading the related // entities into memory is required for successfully executing the selected deletion behavior. @@ -358,7 +358,7 @@ private bool RequiresLoadOfRelationshipForDeletion(RelationshipAttribute relatio INavigation navigation = TryGetNavigation(relationship); bool isClearOfForeignKeyRequired = navigation?.ForeignKey.DeleteBehavior == DeleteBehavior.ClientSetNull; - bool hasForeignKeyAtLeftSide = HasForeignKeyAtLeftSide(relationship); + bool hasForeignKeyAtLeftSide = HasForeignKeyAtLeftSide(relationship, navigation); return isClearOfForeignKeyRequired && !hasForeignKeyAtLeftSide; } @@ -369,15 +369,9 @@ private INavigation TryGetNavigation(RelationshipAttribute relationship) return entityType?.FindNavigation(relationship.Property.Name); } - private bool HasForeignKeyAtLeftSide(RelationshipAttribute relationship) + private bool HasForeignKeyAtLeftSide(RelationshipAttribute relationship, INavigation navigation) { - if (relationship is HasOneAttribute) - { - INavigation navigation = TryGetNavigation(relationship); - return navigation?.IsOnDependent ?? false; - } - - return false; + return relationship is HasOneAttribute && navigation is { IsOnDependent: true }; } /// @@ -506,18 +500,7 @@ private object EnsureRelationshipValueToAssignIsTracked(object rightValue, Type private bool RequireLoadOfInverseRelationship(RelationshipAttribute relationship, object trackedValueToAssign) { // See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. - return trackedValueToAssign != null && relationship.InverseNavigationProperty != null && IsOneToOneRelationship(relationship); - } - - private bool IsOneToOneRelationship(RelationshipAttribute relationship) - { - if (relationship is HasOneAttribute hasOneRelationship) - { - Type elementType = _collectionConverter.TryGetCollectionElementType(hasOneRelationship.InverseNavigationProperty.PropertyType); - return elementType == null; - } - - return false; + return trackedValueToAssign != null && relationship is HasOneAttribute { IsOneToOne: true }; } protected virtual async Task SaveChangesAsync(CancellationToken cancellationToken) diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs index 3a8b3bc16a..166112baed 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs @@ -1,4 +1,6 @@ using System; +using System.Threading; +using JetBrains.Annotations; namespace JsonApiDotNetCore.Resources.Annotations { @@ -10,13 +12,36 @@ namespace JsonApiDotNetCore.Resources.Annotations /// Articles { get; set; } + /// [HasMany] + /// public ISet
Articles { get; set; } /// } /// ]]> /// + [PublicAPI] [AttributeUsage(AttributeTargets.Property)] - public class HasManyAttribute : RelationshipAttribute + public sealed class HasManyAttribute : RelationshipAttribute { + private readonly Lazy _lazyIsManyToMany; + + /// + /// Inspects to determine if this is a many-to-many relationship. + /// + internal bool IsManyToMany => _lazyIsManyToMany.Value; + + public HasManyAttribute() + { + _lazyIsManyToMany = new Lazy(EvaluateIsManyToMany, LazyThreadSafetyMode.PublicationOnly); + } + + private bool EvaluateIsManyToMany() + { + if (InverseNavigationProperty != null) + { + Type elementType = CollectionConverter.TryGetCollectionElementType(InverseNavigationProperty.PropertyType); + return elementType != null; + } + + return false; + } } } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs deleted file mode 100644 index 1ad1a86379..0000000000 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs +++ /dev/null @@ -1,206 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using JetBrains.Annotations; - -// ReSharper disable NonReadonlyMemberInGetHashCode - -namespace JsonApiDotNetCore.Resources.Annotations -{ - /// - /// Used to expose a property on a resource class as a JSON:API to-many relationship (https://jsonapi.org/format/#document-resource-object-relationships) - /// through a many-to-many join relationship. - /// - /// - /// In the following example, we expose a relationship named "tags" through the navigation property `ArticleTags`. The `Tags` property is decorated with - /// `NotMapped` so that EF does not try to map this to a database relationship. - /// Tags { get; set; } - /// public ISet ArticleTags { get; set; } - /// } - /// - /// public class Tag : Identifiable - /// { - /// [Attr] - /// public string Name { get; set; } - /// } - /// - /// public sealed class ArticleTag - /// { - /// public int ArticleId { get; set; } - /// public Article Article { get; set; } - /// - /// public int TagId { get; set; } - /// public Tag Tag { get; set; } - /// } - /// ]]> - /// - [PublicAPI] - [AttributeUsage(AttributeTargets.Property)] - public sealed class HasManyThroughAttribute : HasManyAttribute - { - private static readonly CollectionConverter CollectionConverter = new(); - - /// - /// The name of the join property on the parent resource. In the example described above, this would be "ArticleTags". - /// - public string ThroughPropertyName { get; } - - /// - /// The join type. In the example described above, this would be `ArticleTag`. - /// - public Type ThroughType { get; internal set; } - - /// - /// The navigation property back to the parent resource from the through type. In the example described above, this would point to the - /// `Article.ArticleTags.Article` property. - /// - public PropertyInfo LeftProperty { get; internal set; } - - /// - /// The ID property back to the parent resource from the through type. In the example described above, this would point to the - /// `Article.ArticleTags.ArticleId` property. - /// - public PropertyInfo LeftIdProperty { get; internal set; } - - /// - /// The navigation property to the related resource from the through type. In the example described above, this would point to the - /// `Article.ArticleTags.Tag` property. - /// - public PropertyInfo RightProperty { get; internal set; } - - /// - /// The ID property to the related resource from the through type. In the example described above, this would point to the `Article.ArticleTags.TagId` - /// property. - /// - public PropertyInfo RightIdProperty { get; internal set; } - - /// - /// The join resource property on the parent resource. In the example described above, this would point to the `Article.ArticleTags` property. - /// - public PropertyInfo ThroughProperty { get; internal set; } - - /// - /// The internal navigation property path to the related resource. In the example described above, this would contain "ArticleTags.Tag". - /// - public override string RelationshipPath => $"{ThroughProperty.Name}.{RightProperty.Name}"; - - /// - /// Required for a self-referencing many-to-many relationship. Contains the name of the property back to the parent resource from the through type. - /// - public string LeftPropertyName { get; set; } - - /// - /// Required for a self-referencing many-to-many relationship. Contains the name of the property to the related resource from the through type. - /// - public string RightPropertyName { get; set; } - - /// - /// Optional. Can be used to indicate a non-default name for the ID property back to the parent resource from the through type. Defaults to the name of - /// suffixed with "Id". In the example described above, this would be "ArticleId". - /// - public string LeftIdPropertyName { get; set; } - - /// - /// Optional. Can be used to indicate a non-default name for the ID property to the related resource from the through type. Defaults to the name of - /// suffixed with "Id". In the example described above, this would be "TagId". - /// - public string RightIdPropertyName { get; set; } - - /// - /// Creates a HasMany relationship through a many-to-many join relationship. - /// - /// - /// The name of the navigation property that will be used to access the join relationship. - /// - public HasManyThroughAttribute(string throughPropertyName) - { - ArgumentGuard.NotNullNorEmpty(throughPropertyName, nameof(throughPropertyName)); - - ThroughPropertyName = throughPropertyName; - } - - /// - /// Traverses through the provided resource and returns the value of the relationship on the other side of the through type. In the example described - /// above, this would be the value of "Articles.ArticleTags.Tag". - /// - public override object GetValue(object resource) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - - object throughEntity = ThroughProperty.GetValue(resource); - - if (throughEntity == null) - { - return null; - } - - IEnumerable rightResources = ((IEnumerable)throughEntity).Cast().Select(rightResource => RightProperty.GetValue(rightResource)); - - return CollectionConverter.CopyToTypedCollection(rightResources, Property.PropertyType); - } - - /// - /// Traverses through the provided resource and sets the value of the relationship on the other side of the through type. In the example described above, - /// this would be the value of "Articles.ArticleTags.Tag". - /// - public override void SetValue(object resource, object newValue) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - - base.SetValue(resource, newValue); - - if (newValue == null) - { - ThroughProperty.SetValue(resource, null); - } - else - { - var throughResources = new List(); - - foreach (IIdentifiable rightResource in (IEnumerable)newValue) - { - object throughEntity = Activator.CreateInstance(ThroughType); - - LeftProperty.SetValue(throughEntity, resource); - RightProperty.SetValue(throughEntity, rightResource); - throughResources.Add(throughEntity); - } - - IEnumerable typedCollection = CollectionConverter.CopyToTypedCollection(throughResources, ThroughProperty.PropertyType); - ThroughProperty.SetValue(resource, typedCollection); - } - } - - public override bool Equals(object obj) - { - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (obj is null || GetType() != obj.GetType()) - { - return false; - } - - var other = (HasManyThroughAttribute)obj; - - return ThroughPropertyName == other.ThroughPropertyName && ThroughType == other.ThroughType && LeftProperty == other.LeftProperty && - LeftIdProperty == other.LeftIdProperty && RightProperty == other.RightProperty && RightIdProperty == other.RightIdProperty && - ThroughProperty == other.ThroughProperty && base.Equals(other); - } - - public override int GetHashCode() - { - return HashCode.Combine(ThroughPropertyName, ThroughType, LeftProperty, LeftIdProperty, RightProperty, RightIdProperty, ThroughProperty, - base.GetHashCode()); - } - } -} diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs index 020bd2b5b2..e0ce2ecc47 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs @@ -1,12 +1,46 @@ using System; +using System.Threading; +using JetBrains.Annotations; namespace JsonApiDotNetCore.Resources.Annotations { /// /// Used to expose a property on a resource class as a JSON:API to-one relationship (https://jsonapi.org/format/#document-resource-object-relationships). /// + /// + /// + /// + [PublicAPI] [AttributeUsage(AttributeTargets.Property)] public sealed class HasOneAttribute : RelationshipAttribute { + private readonly Lazy _lazyIsOneToOne; + + /// + /// Inspects to determine if this is a one-to-one relationship. + /// + internal bool IsOneToOne => _lazyIsOneToOne.Value; + + public HasOneAttribute() + { + _lazyIsOneToOne = new Lazy(EvaluateIsOneToOne, LazyThreadSafetyMode.PublicationOnly); + } + + private bool EvaluateIsOneToOne() + { + if (InverseNavigationProperty != null) + { + Type elementType = CollectionConverter.TryGetCollectionElementType(InverseNavigationProperty.PropertyType); + return elementType == null; + } + + return false; + } } } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs index bb46646faa..17a84eda4d 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs @@ -13,6 +13,8 @@ namespace JsonApiDotNetCore.Resources.Annotations [PublicAPI] public abstract class RelationshipAttribute : ResourceFieldAttribute { + private protected static readonly CollectionConverter CollectionConverter = new(); + /// /// The property name of the EF Core inverse navigation, which may or may not exist. Even if it exists, it may not be exposed as a JSON:API relationship. /// @@ -34,12 +36,9 @@ public abstract class RelationshipAttribute : ResourceFieldAttribute public PropertyInfo InverseNavigationProperty { get; set; } /// - /// The internal navigation property path to the related resource. + /// The parent resource type. This is the type of the class in which this attribute was used. /// - /// - /// In all cases except for relationships, this equals the property name. - /// - public virtual string RelationshipPath => Property.Name; + public Type LeftType { get; internal set; } /// /// The child resource type. This does not necessarily match the navigation property type. In the case of a relationship, @@ -47,16 +46,11 @@ public abstract class RelationshipAttribute : ResourceFieldAttribute /// /// /// Tags { get; set; } // Type => Tag + /// public List Tags { get; set; } // RightType == typeof(Tag) /// ]]> /// public Type RightType { get; internal set; } - /// - /// The parent resource type. This is the type of the class in which this attribute was used. - /// - public Type LeftType { get; internal set; } - /// /// Configures which links to show in the object for this relationship. Defaults to /// , which falls back to and then falls back to @@ -76,7 +70,7 @@ public abstract class RelationshipAttribute : ResourceFieldAttribute /// /// Gets the value of the resource property this attribute was declared on. /// - public virtual object GetValue(object resource) + public object GetValue(object resource) { ArgumentGuard.NotNull(resource, nameof(resource)); @@ -86,7 +80,7 @@ public virtual object GetValue(object resource) /// /// Sets the value of the resource property this attribute was declared on. /// - public virtual void SetValue(object resource, object newValue) + public void SetValue(object resource, object newValue) { ArgumentGuard.NotNull(resource, nameof(resource)); diff --git a/src/JsonApiDotNetCore/Serialization/Building/MetaBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/MetaBuilder.cs index 23ac2d0989..d741e6af26 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/MetaBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/MetaBuilder.cs @@ -8,7 +8,7 @@ namespace JsonApiDotNetCore.Serialization.Building { /// [PublicAPI] - public class MetaBuilder : IMetaBuilder + public sealed class MetaBuilder : IMetaBuilder { private readonly IPaginationContext _paginationContext; private readonly IJsonApiOptions _options; diff --git a/src/JsonApiDotNetCore/Serialization/Client/Internal/IRequestSerializer.cs b/src/JsonApiDotNetCore/Serialization/Client/Internal/IRequestSerializer.cs index d58d7d9957..c1252c06ad 100644 --- a/src/JsonApiDotNetCore/Serialization/Client/Internal/IRequestSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Client/Internal/IRequestSerializer.cs @@ -13,14 +13,14 @@ namespace JsonApiDotNetCore.Serialization.Client.Internal public interface IRequestSerializer { /// - /// Sets the attributes that will be included in the serialized request body. You can use to - /// conveniently access the desired instances. + /// Sets the attributes that will be included in the serialized request body. You can use + /// to conveniently access the desired instances. /// public IReadOnlyCollection AttributesToSerialize { get; set; } /// - /// Sets the relationships that will be included in the serialized request body. You can use to - /// conveniently access the desired instances. + /// Sets the relationships that will be included in the serialized request body. You can use + /// to conveniently access the desired instances. /// public IReadOnlyCollection RelationshipsToSerialize { get; set; } diff --git a/src/JsonApiDotNetCore/Serialization/Client/Internal/RequestSerializer.cs b/src/JsonApiDotNetCore/Serialization/Client/Internal/RequestSerializer.cs index 8faf12c933..e0910b3a58 100644 --- a/src/JsonApiDotNetCore/Serialization/Client/Internal/RequestSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Client/Internal/RequestSerializer.cs @@ -93,7 +93,8 @@ private IReadOnlyCollection GetAttributesToSerialize(IIdentifiabl if (AttributesToSerialize == null) { - return _resourceGraph.GetAttributes(currentResourceType); + ResourceContext resourceContext = _resourceGraph.GetResourceContext(currentResourceType); + return resourceContext.Attributes; } return AttributesToSerialize; diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiSerializationException.cs b/src/JsonApiDotNetCore/Serialization/JsonApiSerializationException.cs index 189f2ede64..15fd8c8075 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiSerializationException.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiSerializationException.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCore.Serialization /// The error that is thrown when (de)serialization of a JSON:API body fails. /// [PublicAPI] - public class JsonApiSerializationException : Exception + public sealed class JsonApiSerializationException : Exception { public string GenericMessage { get; } public string SpecificMessage { get; } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 5a8c4efe91..92064666dc 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -257,11 +257,11 @@ public virtual async Task AddToToManyRelationshipAsync(TId primaryId, string rel AssertHasRelationship(_request.Relationship, relationshipName); - if (secondaryResourceIds.Any() && _request.Relationship is HasManyThroughAttribute hasManyThrough) + if (secondaryResourceIds.Any() && _request.Relationship is HasManyAttribute { IsManyToMany: true } manyToManyRelationship) { // In the case of a many-to-many relationship, creating a duplicate entry in the join table results in a // unique constraint violation. We avoid that by excluding already-existing entries from the set in advance. - await RemoveExistingIdsFromSecondarySetAsync(primaryId, secondaryResourceIds, hasManyThrough, cancellationToken); + await RemoveExistingIdsFromSecondarySetAsync(primaryId, secondaryResourceIds, manyToManyRelationship, cancellationToken); } try @@ -276,10 +276,10 @@ public virtual async Task AddToToManyRelationshipAsync(TId primaryId, string rel } } - private async Task RemoveExistingIdsFromSecondarySetAsync(TId primaryId, ISet secondaryResourceIds, - HasManyThroughAttribute hasManyThrough, CancellationToken cancellationToken) + private async Task RemoveExistingIdsFromSecondarySetAsync(TId primaryId, ISet secondaryResourceIds, HasManyAttribute hasManyRelationship, + CancellationToken cancellationToken) { - QueryLayer queryLayer = _queryLayerComposer.ComposeForHasMany(hasManyThrough, primaryId, secondaryResourceIds); + QueryLayer queryLayer = _queryLayerComposer.ComposeForHasMany(hasManyRelationship, primaryId, secondaryResourceIds); IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(queryLayer, cancellationToken); TResource primaryResource = primaryResources.FirstOrDefault(); diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index 6f8f45151d..98615ced4b 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -47,7 +47,7 @@ public ServiceDiscoveryFacadeTests() public void Can_add_resources_from_assembly_to_graph() { // Arrange - var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, LoggerFactory); + var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, LoggerFactory); facade.AddAssembly(typeof(Person).Assembly); // Act @@ -67,7 +67,7 @@ public void Can_add_resources_from_assembly_to_graph() public void Can_add_resource_from_current_assembly_to_graph() { // Arrange - var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, LoggerFactory); + var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, LoggerFactory); facade.AddCurrentAssembly(); // Act @@ -84,7 +84,7 @@ public void Can_add_resource_from_current_assembly_to_graph() public void Can_add_resource_service_from_current_assembly_to_container() { // Arrange - var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, LoggerFactory); + var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, LoggerFactory); facade.AddCurrentAssembly(); // Act @@ -101,7 +101,7 @@ public void Can_add_resource_service_from_current_assembly_to_container() public void Can_add_resource_repository_from_current_assembly_to_container() { // Arrange - var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, LoggerFactory); + var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, LoggerFactory); facade.AddCurrentAssembly(); // Act @@ -118,7 +118,7 @@ public void Can_add_resource_repository_from_current_assembly_to_container() public void Can_add_resource_definition_from_current_assembly_to_container() { // Arrange - var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, LoggerFactory); + var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, LoggerFactory); facade.AddCurrentAssembly(); // Act diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs index 4273f2e10e..28de3cd6f2 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs @@ -32,7 +32,7 @@ public AtomicCreateResourceWithToManyRelationshipTests( } [Fact] - public async Task Can_create_HasMany_relationship() + public async Task Can_create_OneToMany_relationship() { // Arrange List existingPerformers = _fakers.Performer.Generate(2); @@ -110,7 +110,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_create_HasManyThrough_relationship() + public async Task Can_create_ManyToMany_relationship() { // Arrange List existingTracks = _fakers.MusicTrack.Generate(3); @@ -184,21 +184,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(newPlaylistId); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(3); - playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[0].Id); - playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[1].Id); - playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[2].Id); + playlistInDatabase.Tracks.Should().HaveCount(3); + playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); + playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); + playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[2].Id); }); } @@ -545,7 +536,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_create_with_null_data_in_HasMany_relationship() + public async Task Cannot_create_with_null_data_in_OneToMany_relationship() { // Arrange var requestBody = new @@ -588,7 +579,7 @@ public async Task Cannot_create_with_null_data_in_HasMany_relationship() } [Fact] - public async Task Cannot_create_with_null_data_in_HasManyThrough_relationship() + public async Task Cannot_create_with_null_data_in_ManyToMany_relationship() { // Arrange var requestBody = new diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs index 116d8491fb..a8c6442063 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs @@ -228,7 +228,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_delete_existing_resource_with_HasMany_relationship() + public async Task Can_delete_existing_resource_with_OneToMany_relationship() { // Arrange MusicTrack existingTrack = _fakers.MusicTrack.Generate(); @@ -280,18 +280,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_delete_existing_resource_with_HasManyThrough_relationship() + public async Task Can_delete_existing_resource_with_ManyToMany_relationship() { // Arrange - var existingPlaylistMusicTrack = new PlaylistMusicTrack - { - Playlist = _fakers.Playlist.Generate(), - MusicTrack = _fakers.MusicTrack.Generate() - }; + Playlist existingPlaylist = _fakers.Playlist.Generate(); + existingPlaylist.Tracks = _fakers.MusicTrack.Generate(1); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.PlaylistMusicTracks.Add(existingPlaylistMusicTrack); + dbContext.Playlists.Add(existingPlaylist); await dbContext.SaveChangesAsync(); }); @@ -305,7 +302,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => @ref = new { type = "playlists", - id = existingPlaylistMusicTrack.Playlist.StringId + id = existingPlaylist.StringId } } } @@ -323,14 +320,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - Playlist playlistInDatabase = await dbContext.Playlists.FirstWithIdOrDefaultAsync(existingPlaylistMusicTrack.Playlist.Id); + Playlist playlistInDatabase = await dbContext.Playlists.FirstWithIdOrDefaultAsync(existingPlaylist.Id); playlistInDatabase.Should().BeNull(); - PlaylistMusicTrack playlistTracksInDatabase = await dbContext.PlaylistMusicTracks.FirstOrDefaultAsync(playlistMusicTrack => - playlistMusicTrack.Playlist.Id == existingPlaylistMusicTrack.Playlist.Id); + MusicTrack trackInDatabase = await dbContext.MusicTracks.FirstWithIdOrDefaultAsync(existingPlaylist.Tracks[0].Id); - playlistTracksInDatabase.Should().BeNull(); + trackInDatabase.Should().NotBeNull(); }); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs index 8d44db11eb..415fa2d36c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs @@ -26,7 +26,7 @@ public AtomicLocalIdTests(ExampleIntegrationTestContext { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(newPlaylistId); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); playlistInDatabase.Name.Should().Be(newPlaylistName); - playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(1); - playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Id.Should().Be(newTrackId); - playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Title.Should().Be(newTrackTitle); + playlistInDatabase.Tracks.Should().HaveCount(1); + playlistInDatabase.Tracks[0].Id.Should().Be(newTrackId); + playlistInDatabase.Tracks[0].Title.Should().Be(newTrackTitle); }); } @@ -653,7 +644,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_create_ToOne_relationship_using_local_ID() + public async Task Can_create_ManyToOne_relationship_using_local_ID() { // Arrange string newTrackTitle = _fakers.MusicTrack.Generate().Title; @@ -936,22 +927,13 @@ public async Task Can_create_ManyToMany_relationship_using_local_ID() await _testContext.RunOnDatabaseAsync(async dbContext => { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(newPlaylistId); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); playlistInDatabase.Name.Should().Be(newPlaylistName); - playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(1); - playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Id.Should().Be(newTrackId); - playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Title.Should().Be(newTrackTitle); + playlistInDatabase.Tracks.Should().HaveCount(1); + playlistInDatabase.Tracks[0].Id.Should().Be(newTrackId); + playlistInDatabase.Tracks[0].Title.Should().Be(newTrackTitle); }); } @@ -1187,22 +1169,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(newPlaylistId); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); playlistInDatabase.Name.Should().Be(newPlaylistName); - playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(1); - playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Id.Should().Be(newTrackId); - playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Title.Should().Be(newTrackTitle); + playlistInDatabase.Tracks.Should().HaveCount(1); + playlistInDatabase.Tracks[0].Id.Should().Be(newTrackId); + playlistInDatabase.Tracks[0].Title.Should().Be(newTrackTitle); }); } @@ -1462,23 +1435,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(newPlaylistId); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); playlistInDatabase.Name.Should().Be(newPlaylistName); - playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(3); - playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[0].Id); - playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[1].Id); - playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == newTrackId); + playlistInDatabase.Tracks.Should().HaveCount(3); + playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); + playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); + playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == newTrackId); }); } @@ -1642,18 +1606,7 @@ public async Task Can_remove_from_ManyToMany_relationship_using_local_ID() { // Arrange Playlist existingPlaylist = _fakers.Playlist.Generate(); - - existingPlaylist.PlaylistMusicTracks = new[] - { - new PlaylistMusicTrack - { - MusicTrack = _fakers.MusicTrack.Generate() - }, - new PlaylistMusicTrack - { - MusicTrack = _fakers.MusicTrack.Generate() - } - }; + existingPlaylist.Tracks = _fakers.MusicTrack.Generate(2); string newTrackTitle = _fakers.MusicTrack.Generate().Title; @@ -1714,7 +1667,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "musicTracks", - id = existingPlaylist.PlaylistMusicTracks[1].MusicTrack.StringId + id = existingPlaylist.Tracks[1].StringId } } }, @@ -1763,19 +1716,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(existingPlaylist.Id); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(1); - playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Id.Should().Be(existingPlaylist.PlaylistMusicTracks[0].MusicTrack.Id); + playlistInDatabase.Tracks.Should().HaveCount(1); + playlistInDatabase.Tracks[0].Id.Should().Be(existingPlaylist.Tracks[0].Id); }); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs index e24be429e1..64c964a432 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs @@ -131,19 +131,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(newPlaylistId); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(1); - playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Id.Should().Be(existingTrack.Id); + playlistInDatabase.Tracks.Should().HaveCount(1); + playlistInDatabase.Tracks[0].Id.Should().Be(existingTrack.Id); }); } @@ -310,24 +301,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(existingPlaylist.Id); + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(1); - playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Id.Should().Be(existingTrack.Id); + playlistInDatabase.Tracks.Should().HaveCount(1); + playlistInDatabase.Tracks[0].Id.Should().Be(existingTrack.Id); }); } [Fact] - public async Task Can_update_ToOne_relationship() + public async Task Can_update_ManyToOne_relationship() { // Arrange MusicTrack existingTrack = _fakers.MusicTrack.Generate(); @@ -381,7 +363,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_update_ToMany_relationship() + public async Task Can_update_ManyToMany_relationship() { // Arrange Playlist existingPlaylist = _fakers.Playlist.Generate(); @@ -430,19 +412,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(existingPlaylist.Id); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(1); - playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Id.Should().Be(existingTrack.Id); + playlistInDatabase.Tracks.Should().HaveCount(1); + playlistInDatabase.Tracks[0].Id.Should().Be(existingTrack.Id); }); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrack.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrack.cs index 94733028d9..8c8dcd498f 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrack.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrack.cs @@ -35,5 +35,8 @@ public sealed class MusicTrack : Identifiable [HasMany] public IList Performers { get; set; } + + [HasMany] + public IList OccursIn { get; set; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs index ad45e3bb85..d09892953b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs @@ -10,7 +10,6 @@ public sealed class OperationsDbContext : DbContext { public DbSet Playlists { get; set; } public DbSet MusicTracks { get; set; } - public DbSet PlaylistMusicTracks { get; set; } public DbSet Lyrics { get; set; } public DbSet TextLanguages { get; set; } public DbSet Performers { get; set; } @@ -23,17 +22,14 @@ public OperationsDbContext(DbContextOptions options) protected override void OnModelCreating(ModelBuilder builder) { - builder.Entity() - .HasKey(playlistMusicTrack => new - { - playlistMusicTrack.PlaylistId, - playlistMusicTrack.MusicTrackId - }); - builder.Entity() .HasOne(musicTrack => musicTrack.Lyric) .WithOne(lyric => lyric.Track) .HasForeignKey("LyricId"); + + builder.Entity() + .HasMany(musicTrack => musicTrack.OccursIn) + .WithMany(playlist => playlist.Tracks); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Playlist.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Playlist.cs index 5b0713c45b..51e5bc07d0 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Playlist.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Playlist.cs @@ -18,10 +18,7 @@ public sealed class Playlist : Identifiable [Attr] public bool IsArchived => false; - [NotMapped] - [HasManyThrough(nameof(PlaylistMusicTracks))] + [HasMany] public IList Tracks { get; set; } - - public IList PlaylistMusicTracks { get; set; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PlaylistMusicTrack.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PlaylistMusicTrack.cs deleted file mode 100644 index 47540cafdf..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PlaylistMusicTrack.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using JetBrains.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class PlaylistMusicTrack - { - public long PlaylistId { get; set; } - public Playlist Playlist { get; set; } - - public Guid MusicTrackId { get; set; } - public MusicTrack MusicTrack { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs index 1ee2823561..901d6bde71 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs @@ -28,7 +28,7 @@ public AtomicAddToToManyRelationshipTests(ExampleIntegrationTestContext } [Fact] - public async Task Can_add_to_HasMany_relationship() + public async Task Can_add_to_OneToMany_relationship() { // Arrange MusicTrack existingTrack = _fakers.MusicTrack.Generate(); @@ -159,18 +159,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_add_to_HasManyThrough_relationship() + public async Task Can_add_to_ManyToMany_relationship() { // Arrange Playlist existingPlaylist = _fakers.Playlist.Generate(); - - existingPlaylist.PlaylistMusicTracks = new List - { - new() - { - MusicTrack = _fakers.MusicTrack.Generate() - } - }; + existingPlaylist.Tracks = _fakers.MusicTrack.Generate(1); List existingTracks = _fakers.MusicTrack.Generate(2); @@ -236,23 +229,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(existingPlaylist.Id); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - Guid initialTrackId = existingPlaylist.PlaylistMusicTracks[0].MusicTrack.Id; + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(3); - playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == initialTrackId); - playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[0].Id); - playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[1].Id); + playlistInDatabase.Tracks.Should().HaveCount(3); + playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingPlaylist.Tracks[0].Id); + playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); + playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); }); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs index 9fddd90b69..57d0239f02 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs @@ -28,7 +28,7 @@ public AtomicRemoveFromToManyRelationshipTests(ExampleIntegrationTestContext } [Fact] - public async Task Can_remove_from_HasMany_relationship() + public async Task Can_remove_from_OneToMany_relationship() { // Arrange MusicTrack existingTrack = _fakers.MusicTrack.Generate(); @@ -158,26 +158,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_remove_from_HasManyThrough_relationship() + public async Task Can_remove_from_ManyToMany_relationship() { // Arrange Playlist existingPlaylist = _fakers.Playlist.Generate(); - - existingPlaylist.PlaylistMusicTracks = new List - { - new() - { - MusicTrack = _fakers.MusicTrack.Generate() - }, - new() - { - MusicTrack = _fakers.MusicTrack.Generate() - }, - new() - { - MusicTrack = _fakers.MusicTrack.Generate() - } - }; + existingPlaylist.Tracks = _fakers.MusicTrack.Generate(3); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -204,7 +189,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "musicTracks", - id = existingPlaylist.PlaylistMusicTracks[0].MusicTrack.StringId + id = existingPlaylist.Tracks[0].StringId } } }, @@ -222,7 +207,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "musicTracks", - id = existingPlaylist.PlaylistMusicTracks[2].MusicTrack.StringId + id = existingPlaylist.Tracks[2].StringId } } } @@ -241,21 +226,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(existingPlaylist.Id); + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(1); - playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Id.Should().Be(existingPlaylist.PlaylistMusicTracks[1].MusicTrack.Id); + playlistInDatabase.Tracks.Should().HaveCount(1); + playlistInDatabase.Tracks[0].Id.Should().Be(existingPlaylist.Tracks[1].Id); List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + tracksInDatabase.Should().HaveCount(3); }); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs index a4fab39fd5..29074f742c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs @@ -28,7 +28,7 @@ public AtomicReplaceToManyRelationshipTests(ExampleIntegrationTestContext } [Fact] - public async Task Can_clear_HasManyThrough_relationship() + public async Task Can_clear_ManyToMany_relationship() { // Arrange Playlist existingPlaylist = _fakers.Playlist.Generate(); - - existingPlaylist.PlaylistMusicTracks = new List - { - new() - { - MusicTrack = _fakers.MusicTrack.Generate() - }, - new() - { - MusicTrack = _fakers.MusicTrack.Generate() - } - }; + existingPlaylist.Tracks = _fakers.MusicTrack.Generate(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -135,26 +124,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(existingPlaylist.Id); + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - playlistInDatabase.PlaylistMusicTracks.Should().BeEmpty(); + playlistInDatabase.Tracks.Should().BeEmpty(); List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + tracksInDatabase.Should().HaveCount(2); }); } [Fact] - public async Task Can_replace_HasMany_relationship() + public async Task Can_replace_OneToMany_relationship() { // Arrange MusicTrack existingTrack = _fakers.MusicTrack.Generate(); @@ -224,18 +205,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_replace_HasManyThrough_relationship() + public async Task Can_replace_ManyToMany_relationship() { // Arrange Playlist existingPlaylist = _fakers.Playlist.Generate(); - - existingPlaylist.PlaylistMusicTracks = new List - { - new() - { - MusicTrack = _fakers.MusicTrack.Generate() - } - }; + existingPlaylist.Tracks = _fakers.MusicTrack.Generate(1); List existingTracks = _fakers.MusicTrack.Generate(2); @@ -289,22 +263,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(existingPlaylist.Id); + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(2); - playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[0].Id); - playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[1].Id); + playlistInDatabase.Tracks.Should().HaveCount(2); + playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); + playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + tracksInDatabase.Should().HaveCount(3); }); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs index a2f7b4c0ba..c1331532fe 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs @@ -28,7 +28,7 @@ public AtomicReplaceToManyRelationshipTests(ExampleIntegrationTestContext } [Fact] - public async Task Can_clear_HasManyThrough_relationship() + public async Task Can_clear_ManyToMany_relationship() { // Arrange Playlist existingPlaylist = _fakers.Playlist.Generate(); - - existingPlaylist.PlaylistMusicTracks = new List - { - new() - { - MusicTrack = _fakers.MusicTrack.Generate() - }, - new() - { - MusicTrack = _fakers.MusicTrack.Generate() - } - }; + existingPlaylist.Tracks = _fakers.MusicTrack.Generate(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -145,26 +134,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(existingPlaylist.Id); + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - playlistInDatabase.PlaylistMusicTracks.Should().BeEmpty(); + playlistInDatabase.Tracks.Should().BeEmpty(); List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + tracksInDatabase.Should().HaveCount(2); }); } [Fact] - public async Task Can_replace_HasMany_relationship() + public async Task Can_replace_OneToMany_relationship() { // Arrange MusicTrack existingTrack = _fakers.MusicTrack.Generate(); @@ -239,18 +220,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_replace_HasManyThrough_relationship() + public async Task Can_replace_ManyToMany_relationship() { // Arrange Playlist existingPlaylist = _fakers.Playlist.Generate(); - - existingPlaylist.PlaylistMusicTracks = new List - { - new() - { - MusicTrack = _fakers.MusicTrack.Generate() - } - }; + existingPlaylist.Tracks = _fakers.MusicTrack.Generate(1); List existingTracks = _fakers.MusicTrack.Generate(2); @@ -309,22 +283,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(existingPlaylist.Id); + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(2); - playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[0].Id); - playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[1].Id); + playlistInDatabase.Tracks.Should().HaveCount(2); + playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); + playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + tracksInDatabase.Should().HaveCount(3); }); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs index 9593eb4af5..08c03bc049 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs @@ -187,7 +187,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Get_HasOne_relationship_returns_absolute_links() + public async Task Get_ToOne_relationship_returns_absolute_links() { // Arrange Photo photo = _fakers.Photo.Generate(); @@ -220,7 +220,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Get_HasMany_relationship_returns_absolute_links() + public async Task Get_ToMany_relationship_returns_absolute_links() { // Arrange PhotoAlbum album = _fakers.PhotoAlbum.Generate(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs index 28e1256bea..9882777b17 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs @@ -187,7 +187,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Get_HasOne_relationship_returns_absolute_links() + public async Task Get_ToOne_relationship_returns_absolute_links() { // Arrange Photo photo = _fakers.Photo.Generate(); @@ -220,7 +220,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Get_HasMany_relationship_returns_absolute_links() + public async Task Get_ToMany_relationship_returns_absolute_links() { // Arrange PhotoAlbum album = _fakers.PhotoAlbum.Generate(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs index d8f732c7f8..020b66bba4 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs @@ -185,7 +185,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Get_HasOne_relationship_returns_relative_links() + public async Task Get_ToOne_relationship_returns_relative_links() { // Arrange Photo photo = _fakers.Photo.Generate(); @@ -218,7 +218,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Get_HasMany_relationship_returns_relative_links() + public async Task Get_ToMany_relationship_returns_relative_links() { // Arrange PhotoAlbum album = _fakers.PhotoAlbum.Generate(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs index efc1015511..c128aa3de9 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs @@ -185,7 +185,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Get_HasOne_relationship_returns_relative_links() + public async Task Get_ToOne_relationship_returns_relative_links() { // Arrange Photo photo = _fakers.Photo.Generate(); @@ -218,7 +218,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Get_HasMany_relationship_returns_relative_links() + public async Task Get_ToMany_relationship_returns_relative_links() { // Arrange PhotoAlbum album = _fakers.PhotoAlbum.Generate(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs index fc9f15c3ce..8265ee945b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs @@ -228,7 +228,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_get_HasMany_relationship_for_other_parent_tenant() + public async Task Cannot_get_ToMany_relationship_for_other_parent_tenant() { // Arrange WebShop shop = _fakers.WebShop.Generate(); @@ -258,7 +258,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_get_HasOne_relationship_for_other_parent_tenant() + public async Task Cannot_get_ToOne_relationship_for_other_parent_tenant() { // Arrange WebProduct product = _fakers.WebProduct.Generate(); @@ -329,7 +329,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_create_resource_with_HasMany_relationship_to_other_tenant() + public async Task Cannot_create_resource_with_ToMany_relationship_to_other_tenant() { // Arrange WebProduct existingProduct = _fakers.WebProduct.Generate(); @@ -387,7 +387,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_create_resource_with_HasOne_relationship_to_other_tenant() + public async Task Cannot_create_resource_with_ToOne_relationship_to_other_tenant() { // Arrange WebShop existingShop = _fakers.WebShop.Generate(); @@ -534,7 +534,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_update_resource_with_HasMany_relationship_to_other_tenant() + public async Task Cannot_update_resource_with_ToMany_relationship_to_other_tenant() { // Arrange WebShop existingShop = _fakers.WebShop.Generate(); @@ -590,7 +590,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_update_resource_with_HasOne_relationship_to_other_tenant() + public async Task Cannot_update_resource_with_ToOne_relationship_to_other_tenant() { // Arrange WebProduct existingProduct = _fakers.WebProduct.Generate(); @@ -643,7 +643,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_update_HasMany_relationship_for_other_parent_tenant() + public async Task Cannot_update_ToMany_relationship_for_other_parent_tenant() { // Arrange WebShop existingShop = _fakers.WebShop.Generate(); @@ -678,7 +678,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_update_HasMany_relationship_to_other_tenant() + public async Task Cannot_update_ToMany_relationship_to_other_tenant() { // Arrange WebShop existingShop = _fakers.WebShop.Generate(); @@ -723,7 +723,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_update_HasOne_relationship_for_other_parent_tenant() + public async Task Cannot_update_ToOne_relationship_for_other_parent_tenant() { // Arrange WebProduct existingProduct = _fakers.WebProduct.Generate(); @@ -758,7 +758,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_update_HasOne_relationship_to_other_tenant() + public async Task Cannot_update_ToOne_relationship_to_other_tenant() { // Arrange WebProduct existingProduct = _fakers.WebProduct.Generate(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/BlogPost.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/BlogPost.cs index 8805bda98a..7254233f80 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/BlogPost.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/BlogPost.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.ComponentModel.DataAnnotations.Schema; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -21,12 +20,9 @@ public sealed class BlogPost : Identifiable [HasOne] public WebAccount Reviewer { get; set; } - [NotMapped] - [HasManyThrough(nameof(BlogPostLabels))] + [HasMany] public ISet