diff --git a/src/Examples/DapperExample/Repositories/DapperRepository.cs b/src/Examples/DapperExample/Repositories/DapperRepository.cs index 716db19519..995fddf9e2 100644 --- a/src/Examples/DapperExample/Repositories/DapperRepository.cs +++ b/src/Examples/DapperExample/Repositories/DapperRepository.cs @@ -252,9 +252,9 @@ private async Task ApplyTargetedFieldsAsync(TResource resourceFromRequest, TReso relationship.SetValue(resourceInDatabase, rightValueEvaluated); } - foreach (AttrAttribute attribute in _targetedFields.Attributes) + foreach (ITargetedAttributeTree target in _targetedFields.Attributes) { - attribute.SetValue(resourceInDatabase, attribute.GetValue(resourceFromRequest)); + target.Apply(resourceFromRequest, resourceInDatabase); } } diff --git a/src/Examples/GettingStarted/Data/SampleDbContext.cs b/src/Examples/GettingStarted/Data/SampleDbContext.cs index cd8b16515d..1c3ab32f23 100644 --- a/src/Examples/GettingStarted/Data/SampleDbContext.cs +++ b/src/Examples/GettingStarted/Data/SampleDbContext.cs @@ -2,6 +2,8 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +// @formatter:wrap_chained_method_calls chop_always + namespace GettingStarted.Data; [UsedImplicitly(ImplicitUseTargetFlags.Members)] @@ -9,4 +11,21 @@ public class SampleDbContext(DbContextOptions options) : DbContext(options) { public DbSet Books => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Entity() + .OwnsOne(person => person.LivingAddress) + .ToJson(); + + builder.Entity() + .OwnsOne(person => person.MailAddress) + .ToJson(); + + builder.Entity() + .OwnsMany(person => person.Addresses) + .ToJson(); + } } diff --git a/src/Examples/GettingStarted/GettingStarted.http b/src/Examples/GettingStarted/GettingStarted.http new file mode 100644 index 0000000000..e19cff6ca9 --- /dev/null +++ b/src/Examples/GettingStarted/GettingStarted.http @@ -0,0 +1,172 @@ +@hostname=localhost +@port=14141 + +### Get all + +GET http://{{hostname}}:{{port}}/api/people + +### Get all with include + +GET http://{{hostname}}:{{port}}/api/people?include=books + +### Filter inside compound attribute chain + +GET http://{{hostname}}:{{port}}/api/people?filter=startsWith(livingAddress.country.code,'N') + +### Filter on collection attribute with "count" function + +GET http://{{hostname}}:{{port}}/api/people?filter=greaterThan(count(namesOfChildren),'2') + +### Filter on collection attribute with "has" function + +GET http://{{hostname}}:{{port}}/api/people?filter=not(has(addresses)) + +### Filter on collection attribute with "has" function, taking nested filter + +GET http://{{hostname}}:{{port}}/api/people?filter=has(addresses,equals(country.code,'ESP')) + +### Patch string collection attribute + +PATCH http://{{hostname}}:{{port}}/api/people/1 +Content-Type: application/vnd.api+json + +{ + "data": { + "type": "people", + "id": "1", + "attributes": { + "namesOfChildren": [ + "Mary", + "Ann", + null + ] + } + } +} + +### Patch int collection attribute + +PATCH http://{{hostname}}:{{port}}/api/people/1 +Content-Type: application/vnd.api+json + +{ + "data": { + "type": "people", + "id": "1", + "attributes": { + "agesOfChildren": [ + 15, + 25, + null + ] + } + } +} + +### Patch members of compound attribute + +PATCH http://{{hostname}}:{{port}}/api/people/1 +Content-Type: application/vnd.api+json + +{ + "data": { + "type": "people", + "id": "1", + "attributes": { + "livingAddress": { + "country": { + "code": "ITA", + "displayName": "Italy" + }, + "street": "OtherStreet" + } + } + } +} + +### Patch members of compound attribute, setting them to null + +PATCH http://{{hostname}}:{{port}}/api/people/1 +Content-Type: application/vnd.api+json + +{ + "data": { + "type": "people", + "id": "1", + "attributes": { + "livingAddress": { + "country": null, + "street": null + } + } + } +} + +### Patch compound attribute to empty object (must be a no-op) + +PATCH http://{{hostname}}:{{port}}/api/people/1 +Content-Type: application/vnd.api+json + +{ + "data": { + "type": "people", + "id": "1", + "attributes": { + "mailAddress": {} + } + } +} + +### Patch compound attribute to null + +PATCH http://{{hostname}}:{{port}}/api/people/1 +Content-Type: application/vnd.api+json + +{ + "data": { + "type": "people", + "id": "1", + "attributes": { + "mailAddress": null + } + } +} + +### Patch compound nullable collection attribute (null element is blocked by EF Core) + +PATCH http://{{hostname}}:{{port}}/api/people/1 +Content-Type: application/vnd.api+json + +{ + "data": { + "type": "people", + "id": "1", + "attributes": { + "addresses": [ + { + "country": { + "code": "DEU", + "displayName": "Germany" + } + }, + { + "street": "SomeStreet" + }, + {} + ] + } + } +} + +### Patch relationships to null - SHOULD THIS FAIL OR NO-OP? + +PATCH http://{{hostname}}:{{port}}/api/people/1 +Content-Type: application/vnd.api+json + +{ + "data": { + "type": "people", + "id": "1", + "relationships": null + } +} diff --git a/src/Examples/GettingStarted/Models/Address.cs b/src/Examples/GettingStarted/Models/Address.cs new file mode 100644 index 0000000000..1ed5d3fbb2 --- /dev/null +++ b/src/Examples/GettingStarted/Models/Address.cs @@ -0,0 +1,19 @@ +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.EntityFrameworkCore; + +namespace GettingStarted.Models; + +[Owned] +public sealed class Address +{ + [Attr] + public string? Street { get; set; } + + [Attr] + public string? PostalCode { get; set; } + + [Attr(IsCompound = true)] + public Country? Country { get; set; } + + public string? NotExposed { get; set; } +} diff --git a/src/Examples/GettingStarted/Models/Country.cs b/src/Examples/GettingStarted/Models/Country.cs new file mode 100644 index 0000000000..49efac29b3 --- /dev/null +++ b/src/Examples/GettingStarted/Models/Country.cs @@ -0,0 +1,14 @@ +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.EntityFrameworkCore; + +namespace GettingStarted.Models; + +[Owned] +public sealed class Country +{ + [Attr] + public required string Code { get; set; } + + [Attr] + public string? DisplayName { get; set; } +} diff --git a/src/Examples/GettingStarted/Models/Person.cs b/src/Examples/GettingStarted/Models/Person.cs index 89ca4c5a69..b9c3abb083 100644 --- a/src/Examples/GettingStarted/Models/Person.cs +++ b/src/Examples/GettingStarted/Models/Person.cs @@ -8,8 +8,26 @@ namespace GettingStarted.Models; [Resource] public sealed class Person : Identifiable { + // TODO: See if we can use "required" throughout the codebase, instead of null! suppression. + + [Attr] + public required string Name { get; set; } + + [Attr(IsCompound = true)] + public required Address LivingAddress { get; set; } + + [Attr(IsCompound = true)] + public Address? MailAddress { get; set; } + + // OwnsMany with nullable element type is unsupported by EF Core. + [Attr(IsCompound = true)] + public List
? Addresses { get; set; } + + [Attr] + public List NamesOfChildren { get; set; } = []; + [Attr] - public string Name { get; set; } = null!; + public List AgesOfChildren { get; set; } = []; [HasMany] public ICollection Books { get; set; } = new List(); diff --git a/src/Examples/GettingStarted/Program.cs b/src/Examples/GettingStarted/Program.cs index 634e130a3f..250f54fcfa 100644 --- a/src/Examples/GettingStarted/Program.cs +++ b/src/Examples/GettingStarted/Program.cs @@ -5,6 +5,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; +#pragma warning disable format + WebApplicationBuilder builder = WebApplication.CreateBuilder(args); // Add services to the container. @@ -64,30 +66,94 @@ static async Task CreateSampleDataAsync(SampleDbContext dbContext) // Note: The generate-examples.ps1 script (to create example requests in documentation) depends on these. dbContext.Books.AddRange(new Book - { - Title = "Frankenstein", - PublishYear = 1818, - Author = new Person { - Name = "Mary Shelley" + Title = "Frankenstein", + PublishYear = 1818, + Author = new Person + { + Name = "Mary Shelley", + LivingAddress = new Address + { + Street = "SomeStreet", + PostalCode = "1234 AB", + Country = new Country + { + Code = "NLD", + DisplayName = "The Netherlands" + }, + NotExposed = "NotExposed" + }, + MailAddress = new Address + { + Street = "MailStreet", + PostalCode = "MailPostalCode", + Country = new Country + { + Code = "MailCode", + DisplayName = "MailCountryName" + }, + NotExposed = "MailNotExposed" + }, + Addresses = + [ + new Address + { + Street = "Street1", + PostalCode = "PostalCode1", + Country = new Country + { + Code = "ESP", + DisplayName = "Spain" + }, + NotExposed = "NotExposed1" + }, + new Address + { + Street = "Street2", + PostalCode = "PostalCode2", + Country = new Country + { + Code = "Country2", + DisplayName = "CountryName2" + }, + NotExposed = "NotExposed2" + } + ], + NamesOfChildren = + [ + "John", + "Jack", + "Joe", + null + ], + AgesOfChildren = + [ + 10, + 20, + 30, + null + ] + } } - }, new Book - { - Title = "Robinson Crusoe", - PublishYear = 1719, - Author = new Person + /*, new Book { - Name = "Daniel Defoe" - } - }, new Book - { - Title = "Gulliver's Travels", - PublishYear = 1726, - Author = new Person + Title = "Robinson Crusoe", + PublishYear = 1719, + Author = new Person + { + Name = "Daniel Defoe" + } + }, new Book { - Name = "Jonathan Swift" - } - }); + Title = "Gulliver's Travels", + PublishYear = 1726, + Author = new Person + { + Name = "Jonathan Swift" + } + }*/ + ); +#pragma warning restore format await dbContext.SaveChangesAsync(); } diff --git a/src/Examples/GettingStarted/Properties/launchSettings.json b/src/Examples/GettingStarted/Properties/launchSettings.json index 304c377082..86e10d1864 100644 --- a/src/Examples/GettingStarted/Properties/launchSettings.json +++ b/src/Examples/GettingStarted/Properties/launchSettings.json @@ -10,7 +10,7 @@ "profiles": { "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, + "launchBrowser": false, "launchUrl": "api/people?include=books", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -18,7 +18,7 @@ }, "Kestrel": { "commandName": "Project", - "launchBrowser": true, + "launchBrowser": false, "launchUrl": "api/people?include=books", "applicationUrl": "http://localhost:14141", "environmentVariables": { diff --git a/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs b/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs index 683e34764b..bd8c6665b6 100644 --- a/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs +++ b/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs @@ -40,6 +40,21 @@ public IEnumerable CopyToTypedCollection(IEnumerable source, Type collectionType ArgumentNullException.ThrowIfNull(collectionType); Type concreteCollectionType = ToConcreteCollectionType(collectionType); + + if (concreteCollectionType.IsArray) + { + Type elementType = concreteCollectionType.GetElementType()!; + Type listType = typeof(List<>).MakeGenericType(elementType); + dynamic listInstance = Activator.CreateInstance(listType)!; + + foreach (object item in source) + { + listInstance.Add((dynamic)item); + } + + return listInstance.ToArray(); + } + dynamic concreteCollectionInstance = Activator.CreateInstance(concreteCollectionType)!; foreach (object item in source) @@ -104,18 +119,19 @@ public IReadOnlyCollection ExtractResources(object? value) /// public Type? FindCollectionElementType(Type? type) { - if (type != null) + if (type == null || !IsCollectionType(type)) { - Type? enumerableClosedType = IsEnumerableClosedType(type) ? type : null; - enumerableClosedType ??= type.GetInterfaces().FirstOrDefault(IsEnumerableClosedType); + return null; + } - if (enumerableClosedType != null) - { - return enumerableClosedType.GenericTypeArguments[0]; - } + if (type.IsArray) + { + return type.GetElementType(); } - return null; + Type? enumerableClosedType = IsEnumerableClosedType(type) ? type : null; + enumerableClosedType ??= type.GetInterfaces().FirstOrDefault(IsEnumerableClosedType); + return enumerableClosedType?.GenericTypeArguments[0]; } private static bool IsEnumerableClosedType(Type type) @@ -124,6 +140,17 @@ private static bool IsEnumerableClosedType(Type type) return isClosedType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>); } + /// + /// Indicates whether the specified type is a collection. + /// + /// + /// The type to inspect. + /// + public bool IsCollectionType(Type type) + { + return type != typeof(string) && type != typeof(byte[]) && type.IsAssignableTo(typeof(IEnumerable)); + } + /// /// Indicates whether a instance can be assigned to the specified type, for example: /// +/// The containing type for a JSON:API field, which can be a or an . +/// +public interface IFieldContainer +{ + /// + /// The publicly exposed name of this container. + /// + string PublicName { get; } + + /// + /// The CLR type of this container. + /// + Type ClrType { get; } + + /// + /// Searches the direct children of this container for an attribute with the specified name. + /// + /// + /// The publicly exposed name of the attribute to find. + /// + /// + /// The attribute, or null if not found. + /// + AttrAttribute? FindAttributeByPublicName(string publicName); +} diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs index 4c0cd133f9..fb5b598350 100644 --- a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs +++ b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCore.Configuration; /// Metadata about the shape of a JSON:API resource in the resource graph. /// [PublicAPI] -public sealed class ResourceType +public sealed class ResourceType : IFieldContainer { private static readonly IReadOnlySet EmptyResourceTypeSet = new HashSet().AsReadOnly(); private static readonly IReadOnlySet EmptyAttributeSet = new HashSet().AsReadOnly(); diff --git a/src/JsonApiDotNetCore.Annotations/DictionaryExtensions.cs b/src/JsonApiDotNetCore.Annotations/DictionaryExtensions.cs new file mode 100644 index 0000000000..bd6a571746 --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/DictionaryExtensions.cs @@ -0,0 +1,40 @@ +namespace JsonApiDotNetCore; + +internal static class DictionaryExtensions +{ + public static bool DictionaryEqual(this IReadOnlyDictionary? first, IReadOnlyDictionary? second, + IEqualityComparer? valueComparer = null) + { + if (ReferenceEquals(first, second)) + { + return true; + } + + if (first == null || second == null) + { + return false; + } + + if (first.Count != second.Count) + { + return false; + } + + IEqualityComparer effectiveValueComparer = valueComparer ?? EqualityComparer.Default; + + foreach ((TKey firstKey, TValue firstValue) in first) + { + if (!second.TryGetValue(firstKey, out TValue? secondValue)) + { + return false; + } + + if (!effectiveValueComparer.Equals(firstValue, secondValue)) + { + return false; + } + } + + return true; + } +} diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs index 26a660775a..c2db7ba696 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs @@ -1,4 +1,6 @@ +using System.Collections.ObjectModel; using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; namespace JsonApiDotNetCore.Resources.Annotations; @@ -7,8 +9,10 @@ namespace JsonApiDotNetCore.Resources.Annotations; /// [PublicAPI] [AttributeUsage(AttributeTargets.Property)] -public sealed class AttrAttribute : ResourceFieldAttribute +public sealed class AttrAttribute : ResourceFieldAttribute, IFieldContainer { + private static readonly ReadOnlyDictionary EmptyChildren = new Dictionary().AsReadOnly(); + private AttrCapabilities? _capabilities; internal bool HasExplicitCapabilities => _capabilities != null; @@ -31,6 +35,30 @@ public AttrCapabilities Capabilities set => _capabilities = value; } + /// + /// Indicates whether this attribute contains nested attributes. false by default. + /// + public bool IsCompound { get; set; } + + /// + /// Gets the kind of this attribute. + /// + public AttrKind Kind { get; internal set; } + + /// + /// Gets the nested attributes by name, if this is a compound attribute. + /// + public IReadOnlyDictionary Children { get; internal set; } = EmptyChildren; + + /// + public Type ClrType => Property.PropertyType; + + /// + public AttrAttribute? FindAttributeByPublicName(string publicName) + { + return Children.GetValueOrDefault(publicName); + } + /// public override bool Equals(object? obj) { @@ -46,12 +74,14 @@ public override bool Equals(object? obj) var other = (AttrAttribute)obj; - return Capabilities == other.Capabilities && base.Equals(other); + return Capabilities == other.Capabilities && Kind == other.Kind && Children.DictionaryEqual(other.Children) && base.Equals(other); } /// public override int GetHashCode() { - return HashCode.Combine(Capabilities, base.GetHashCode()); + // ReSharper disable NonReadonlyMemberInGetHashCode + return HashCode.Combine(Capabilities, Kind, Children, base.GetHashCode()); + // ReSharper restore NonReadonlyMemberInGetHashCode } } diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.netstandard.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.netstandard.cs index a7915240dc..0948dada0e 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.netstandard.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.netstandard.cs @@ -11,4 +11,7 @@ public sealed class AttrAttribute : ResourceFieldAttribute { /// public AttrCapabilities Capabilities { get; set; } + + /// + public bool IsCompound { get; set; } } diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrKind.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrKind.cs new file mode 100644 index 0000000000..7e09d7405b --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrKind.cs @@ -0,0 +1,28 @@ +namespace JsonApiDotNetCore.Resources.Annotations; + +/// +/// Indicates the kind of . +/// +public enum AttrKind +{ + /// + /// A primitive attribute, such as , , , , or + /// . + /// + Primitive, + + /// + /// An attribute that contains nested attributes, such as Contact, Address or PhoneNumber. + /// + Compound, + + /// + /// A collection of primitive attributes. + /// + CollectionOfPrimitive, + + /// + /// A collection of compound attributes. + /// + CollectionOfCompound +} diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs index a906f4a667..c30f3cc7c9 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs @@ -89,7 +89,7 @@ private void AssertIsIdentifiableCollection(object newValue) throw new InvalidOperationException("Resource collection must not contain null values."); } - AssertIsIdentifiable(element); + AssertIsIdentifiableOrAttribute(element); } } diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.cs index 51d22f9955..40bce26cb3 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.cs @@ -67,7 +67,7 @@ private bool EvaluateIsOneToOne() /// public override void SetValue(object resource, object? newValue) { - AssertIsIdentifiable(newValue); + AssertIsIdentifiableOrAttribute(newValue); base.SetValue(resource, newValue); } diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs index 21d5bfab1d..ef2bb53733 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs @@ -41,9 +41,14 @@ public abstract class RelationshipAttribute : ResourceFieldAttribute public PropertyInfo? InverseNavigationProperty { get; set; } /// - /// The containing resource type in which this relationship is declared. Identical to . + /// The containing resource type in which this field is declared. /// - public ResourceType LeftType => Type; + public override ResourceType Container => (ResourceType)base.Container; + + /// + /// The containing resource type in which this relationship is declared. Identical to . + /// + public ResourceType LeftType => Container; /// /// The resource type this relationship points to. In the case of a relationship, this value will be the collection diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs index 593b0a905d..a600d691d4 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs @@ -16,7 +16,7 @@ public abstract class ResourceFieldAttribute : Attribute // These are definitely assigned after building the resource graph, which is why their public equivalents are declared as non-nullable. private string? _publicName; private PropertyInfo? _property; - private ResourceType? _type; + private IFieldContainer? _container; /// /// The publicly exposed name of this JSON:API field. When not explicitly set, the configured naming convention is applied on the property name. @@ -49,15 +49,15 @@ internal set } /// - /// The containing resource type in which this field is declared. + /// The container in which this field is declared. /// - public ResourceType Type + public virtual IFieldContainer Container { - get => _type!; + get => _container!; internal set { ArgumentNullException.ThrowIfNull(value); - _type = value; + _container = value; } } @@ -68,7 +68,7 @@ internal set public object? GetValue(object resource) { ArgumentNullException.ThrowIfNull(resource); - AssertIsIdentifiable(resource); + AssertIsIdentifiableOrAttribute(resource); if (Property.GetMethod == null) { @@ -94,7 +94,7 @@ internal set public virtual void SetValue(object resource, object? newValue) { ArgumentNullException.ThrowIfNull(resource); - AssertIsIdentifiable(resource); + AssertIsIdentifiableOrAttribute(resource); if (Property.SetMethod == null) { @@ -113,8 +113,13 @@ public virtual void SetValue(object resource, object? newValue) } } - protected void AssertIsIdentifiable(object? resource) + protected void AssertIsIdentifiableOrAttribute(object? resource) { + if (GetType() == typeof(AttrAttribute)) + { + return; + } + if (resource is not null and not IIdentifiable) { #pragma warning disable CA1062 // Validate arguments of public methods diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ResourceFieldValidationMetadataProvider.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ResourceFieldValidationMetadataProvider.cs index f0ce60c730..68224f31c9 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ResourceFieldValidationMetadataProvider.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ResourceFieldValidationMetadataProvider.cs @@ -73,7 +73,7 @@ public bool IsRequired(ResourceFieldAttribute field) private bool IsModelStateValidationRequired(ResourceFieldAttribute field) { - ModelMetadata modelMetadata = _modelMetadataProvider.GetMetadataForProperty(field.Type.ClrType, field.Property.Name); + ModelMetadata modelMetadata = _modelMetadataProvider.GetMetadataForProperty(field.Container.ClrType, field.Property.Name); // Non-nullable reference types are implicitly required, unless SuppressImplicitRequiredAttributeForNonNullableReferenceTypes is set. return modelMetadata.ValidatorMetadata.Any(validatorMetadata => validatorMetadata is RequiredAttribute); diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/ResourceDocumentationReader.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/ResourceDocumentationReader.cs index cd2727854c..ae0a079d2d 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/ResourceDocumentationReader.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/ResourceDocumentationReader.cs @@ -31,7 +31,7 @@ internal sealed class ResourceDocumentationReader { ArgumentNullException.ThrowIfNull(attribute); - XPathNavigator? navigator = GetNavigator(attribute.Type.ClrType.Assembly); + XPathNavigator? navigator = GetNavigator(attribute.Container.ClrType.Assembly); if (navigator != null) { @@ -46,7 +46,7 @@ internal sealed class ResourceDocumentationReader { ArgumentNullException.ThrowIfNull(relationship); - XPathNavigator? navigator = GetNavigator(relationship.Type.ClrType.Assembly); + XPathNavigator? navigator = GetNavigator(relationship.Container.ClrType.Assembly); if (navigator != null) { diff --git a/src/JsonApiDotNetCore/CollectionExtensions.cs b/src/JsonApiDotNetCore/CollectionExtensions.cs index ca46953bfc..3078ba755b 100644 --- a/src/JsonApiDotNetCore/CollectionExtensions.cs +++ b/src/JsonApiDotNetCore/CollectionExtensions.cs @@ -59,42 +59,6 @@ public static IEnumerable ToEnumerable(this LinkedListNode? startNode) } } - public static bool DictionaryEqual(this IReadOnlyDictionary? first, IReadOnlyDictionary? second, - IEqualityComparer? valueComparer = null) - { - if (ReferenceEquals(first, second)) - { - return true; - } - - if (first == null || second == null) - { - return false; - } - - if (first.Count != second.Count) - { - return false; - } - - IEqualityComparer effectiveValueComparer = valueComparer ?? EqualityComparer.Default; - - foreach ((TKey firstKey, TValue firstValue) in first) - { - if (!second.TryGetValue(firstKey, out TValue? secondValue)) - { - return false; - } - - if (!effectiveValueComparer.Equals(firstValue, secondValue)) - { - return false; - } - } - - return true; - } - public static IEnumerable EmptyIfNull(this IEnumerable? source) { return source ?? Array.Empty(); diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs b/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs index 30b850e8b8..825595f276 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs @@ -71,7 +71,8 @@ private static bool IsAtPrimaryEndpoint(IJsonApiRequest request) private static bool IsFieldTargeted(ValidationEntry entry, ITargetedFields targetedFields) { - return targetedFields.Attributes.Any(attribute => attribute.Property.Name == entry.Key) || + // TODO: Consider compound attributes, ensure proper source pointer. + return targetedFields.Attributes.Any(target => target.Attribute.Property.Name == entry.Key) || targetedFields.Relationships.Any(relationship => relationship.Property.Name == entry.Key); } } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index 8ab2120d92..aed4e355df 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -56,7 +56,21 @@ private static void SetFieldTypes(ResourceGraph resourceGraph) { foreach (ResourceFieldAttribute field in resourceGraph.GetResourceTypes().SelectMany(resourceType => resourceType.Fields)) { - field.Type = resourceGraph.GetResourceType(field.Property.ReflectedType!); + field.Container = resourceGraph.GetResourceType(field.Property.ReflectedType!); + + if (field is AttrAttribute attribute) + { + SetFieldTypeInAttributeChildren(attribute); + } + } + } + + private static void SetFieldTypeInAttributeChildren(AttrAttribute attribute) + { + foreach (AttrAttribute child in attribute.Children.Values) + { + child.Container = attribute; + SetFieldTypeInAttributeChildren(child); } } @@ -243,7 +257,7 @@ private ResourceType CreateResourceType(string publicName, Type resourceClrType, { ClientIdGenerationMode? clientIdGeneration = GetClientIdGeneration(resourceClrType); - Dictionary.ValueCollection attributes = GetAttributes(resourceClrType); + ReadOnlyDictionary.ValueCollection attributes = GetAttributes(resourceClrType, true).Values; Dictionary.ValueCollection relationships = GetRelationships(resourceClrType); ReadOnlyCollection eagerLoads = GetEagerLoads(resourceClrType); @@ -263,19 +277,25 @@ private ResourceType CreateResourceType(string publicName, Type resourceClrType, return resourceAttribute?.NullableClientIdGeneration; } - private Dictionary.ValueCollection GetAttributes(Type resourceClrType) + private ReadOnlyDictionary GetAttributes(Type containerClrType, bool isTopLevel) { + if (!isTopLevel && containerClrType.IsAbstract) + { + // There is no way to indicate the derived type in JSON:API. + throw new InvalidConfigurationException("Resource inheritance is not supported on compound attributes."); + } + var attributesByName = new Dictionary(); - foreach (PropertyInfo property in resourceClrType.GetProperties()) + foreach (PropertyInfo property in containerClrType.GetProperties()) { var attribute = property.GetCustomAttribute(true); if (attribute == null) { - if (property.Name == nameof(Identifiable.Id)) + if (isTopLevel && property.Name == nameof(Identifiable.Id)) { - // Although strictly not correct, 'id' is added to the list of attributes for convenience. + // Although strictly not correct, 'id' is added to the list of resource attributes for convenience. // For example, it enables to filter on ID, without the need to special-case existing logic. // And when using sparse fieldsets, it silently adds 'id' to the set of attributes to retrieve. @@ -292,18 +312,44 @@ private Dictionary.ValueCollection GetAttributes(Type res if (!attribute.HasExplicitCapabilities) { + // TODO: Do capabilities of a nested attribute have any meaning? attribute.Capabilities = _options.DefaultAttrCapabilities; } + bool isCollection = CollectionConverter.Instance.IsCollectionType(property.PropertyType); + attribute.Kind = ToAttrKind(attribute.IsCompound, isCollection); + + if (attribute.Kind == AttrKind.Compound) + { + attribute.Children = GetAttributes(property.PropertyType, false); + } + else if (attribute.Kind == AttrKind.CollectionOfCompound) + { + Type elementType = CollectionConverter.Instance.FindCollectionElementType(property.PropertyType)!; + attribute.Children = GetAttributes(elementType, false); + } + IncludeField(attributesByName, attribute); } - if (attributesByName.Count < 2) + bool hasAttributes = isTopLevel ? attributesByName.Count > 1 : attributesByName.Count > 0; + + if (!hasAttributes) + { + LogContainerTypeHasNoAttributes(containerClrType); + } + + return attributesByName.AsReadOnly(); + } + + private static AttrKind ToAttrKind(bool isCompound, bool isCollection) + { + if (isCompound) { - LogResourceTypeHasNoAttributes(resourceClrType); + return isCollection ? AttrKind.CollectionOfCompound : AttrKind.Compound; } - return attributesByName.Values; + return isCollection ? AttrKind.CollectionOfPrimitive : AttrKind.Primitive; } private Dictionary.ValueCollection GetRelationships(Type resourceClrType) @@ -479,6 +525,6 @@ private string FormatPropertyName(PropertyInfo resourceProperty) Message = "Skipping: Type '{ResourceType}' does not implement '{InterfaceType}'. Add [NoResource] to suppress this warning.")] private partial void LogResourceTypeDoesNotImplementInterface(Type resourceType, string interfaceType); - [LoggerMessage(Level = LogLevel.Warning, Message = "Type '{ResourceType}' does not contain any attributes.")] - private partial void LogResourceTypeHasNoAttributes(Type resourceType); + [LoggerMessage(Level = LogLevel.Warning, Message = "Type '{ContainerType}' does not contain any attributes.")] + private partial void LogContainerTypeHasNoAttributes(Type containerType); } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs index 35d5ecc4a1..ba4ca99d0e 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs @@ -50,7 +50,7 @@ public override string ToString() public override string ToFullString() { - return string.Join(".", Fields.Select(field => $"{field.Type.PublicName}:{field.PublicName}")); + return string.Join(".", Fields.Select(field => $"{field.Container.PublicName}:{field.PublicName}")); } public override bool Equals(object? obj) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs index e075c3f915..5b3ead7bf4 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs @@ -38,7 +38,7 @@ public override string ToString() public override string ToFullString() { - return string.Join(".", Fields.Select(field => $"{field.Type.PublicName}:{field.PublicName}").OrderBy(name => name)); + return string.Join(".", Fields.Select(field => $"{field.Container.PublicName}:{field.PublicName}").OrderBy(name => name)); } public override bool Equals(object? obj) diff --git a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs index f89bf3dc39..6e93542380 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs @@ -34,21 +34,21 @@ public class FilterParser : QueryExpressionParser, IFilterParser ]; private readonly IResourceFactory _resourceFactory; - private readonly Stack _resourceTypeStack = new(); + private readonly Stack _fieldContainerStack = new(); /// - /// Gets the resource type currently in scope. Call to temporarily change the current resource type. + /// Gets the field container currently in scope. Call to temporarily change the current field container. /// - protected ResourceType ResourceTypeInScope + protected IFieldContainer ContainerInScope { get { - if (_resourceTypeStack.Count == 0) + if (_fieldContainerStack.Count == 0) { throw new InvalidOperationException("No resource type is currently in scope. Call Parse() first."); } - return _resourceTypeStack.Peek(); + return _fieldContainerStack.Peek(); } } @@ -66,10 +66,10 @@ public FilterExpression Parse(string source, ResourceType resourceType) Tokenize(source); - _resourceTypeStack.Clear(); + _fieldContainerStack.Clear(); FilterExpression expression; - using (InScopeOfResourceType(resourceType)) + using (InScopeOfContainer(resourceType)) { expression = ParseFilter(); @@ -110,7 +110,7 @@ private CountExpression ParseCount() EatSingleCharacterToken(TokenKind.OpenParen); ResourceFieldChainExpression targetCollection = - ParseFieldChain(BuiltInPatterns.ToOneChainEndingInToMany, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInToMany, FieldChainPatternMatchOptions.None, ContainerInScope, null); EatSingleCharacterToken(TokenKind.CloseParen); @@ -240,7 +240,7 @@ private QueryExpression ParseComparisonLeftTerm(ComparisonOperator comparisonOpe ? BuiltInPatterns.ToOneChainEndingInAttributeOrToOne : BuiltInPatterns.ToOneChainEndingInAttribute; - return ParseFieldChain(pattern, FieldChainPatternMatchOptions.None, ResourceTypeInScope, "Function or field name expected."); + return ParseFieldChain(pattern, FieldChainPatternMatchOptions.None, ContainerInScope, "Function or field name expected."); } private QueryExpression ParseComparisonRightTerm(QueryExpression leftTerm) @@ -305,7 +305,7 @@ private QueryExpression ParseTypedComparisonRightTerm(Type leftType, ConstantVal return ParseFunction(); } - return ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, errorMessage); + return ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ContainerInScope, errorMessage); } } @@ -323,7 +323,7 @@ protected virtual MatchTextExpression ParseTextMatch(string operatorName) int chainStartPosition = GetNextTokenPositionOrEnd(); ResourceFieldChainExpression targetAttributeChain = - ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ContainerInScope, null); var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; @@ -350,7 +350,7 @@ protected virtual AnyExpression ParseAny() EatSingleCharacterToken(TokenKind.OpenParen); ResourceFieldChainExpression targetAttributeChain = - ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ContainerInScope, null); var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; @@ -383,7 +383,7 @@ protected virtual HasExpression ParseHas() EatSingleCharacterToken(TokenKind.OpenParen); ResourceFieldChainExpression targetCollection = - ParseFieldChain(BuiltInPatterns.ToOneChainEndingInToMany, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInToMany, FieldChainPatternMatchOptions.None, ContainerInScope, null); FilterExpression? filter = null; @@ -391,11 +391,23 @@ protected virtual HasExpression ParseHas() { EatSingleCharacterToken(TokenKind.Comma); - var hasManyRelationship = (HasManyAttribute)targetCollection.Fields[^1]; - - using (InScopeOfResourceType(hasManyRelationship.RightType)) + if (targetCollection.Fields[^1] is HasManyAttribute hasManyRelationship) { - filter = ParseFilter(); + using (InScopeOfContainer(hasManyRelationship.RightType)) + { + filter = ParseFilter(); + } + } + else if (targetCollection.Fields[^1] is AttrAttribute { Kind: AttrKind.CollectionOfPrimitive or AttrKind.CollectionOfCompound } collectionAttribute) + { + using (InScopeOfContainer(collectionAttribute)) + { + filter = ParseFilter(); + } + } + else + { + throw new NotImplementedException("TODO: This should become unreachable after refactoring pattern syntax."); } } @@ -413,7 +425,12 @@ protected virtual IsTypeExpression ParseIsType() EatSingleCharacterToken(TokenKind.Comma); - ResourceType baseType = targetToOneRelationship != null ? ((RelationshipAttribute)targetToOneRelationship.Fields[^1]).RightType : ResourceTypeInScope; + if (ContainerInScope is not ResourceType resourceTypeInScope) + { + throw new InvalidOperationException("TODO: Fail when this function is used on a nested attribute."); + } + + ResourceType baseType = targetToOneRelationship != null ? ((RelationshipAttribute)targetToOneRelationship.Fields[^1]).RightType : resourceTypeInScope; ResourceType derivedType = ParseDerivedType(baseType); FilterExpression? child = TryParseFilterInIsType(derivedType); @@ -430,7 +447,7 @@ protected virtual IsTypeExpression ParseIsType() return null; } - return ParseFieldChain(BuiltInPatterns.ToOneChain, FieldChainPatternMatchOptions.None, ResourceTypeInScope, "Relationship name or , expected."); + return ParseFieldChain(BuiltInPatterns.ToOneChain, FieldChainPatternMatchOptions.None, ContainerInScope, "Relationship name or , expected."); } private ResourceType ParseDerivedType(ResourceType baseType) @@ -486,7 +503,7 @@ private static ResourceType ResolveDerivedType(ResourceType baseType, string der { EatSingleCharacterToken(TokenKind.Comma); - using (InScopeOfResourceType(derivedType)) + using (InScopeOfContainer(derivedType)) { filter = ParseFilter(); } @@ -539,13 +556,13 @@ protected virtual ConstantValueConverter GetConstantValueConverterForType(Type d private ConstantValueConverter GetConstantValueConverterForAttribute(AttrAttribute attribute) { - if (attribute is { Property.Name: nameof(Identifiable.Id) }) + if (attribute is { Property.Name: nameof(Identifiable.Id), Container: ResourceType resourceType }) { return (stringValue, position) => { try { - return DeObfuscateStringId(attribute.Type, stringValue); + return DeObfuscateStringId(resourceType, stringValue); } catch (JsonApiException exception) { @@ -578,30 +595,30 @@ protected override void ValidateField(ResourceFieldAttribute field, int position /// /// Changes the resource type currently in scope and restores the original resource type when the return value is disposed. /// - protected IDisposable InScopeOfResourceType(ResourceType resourceType) + protected IDisposable InScopeOfContainer(IFieldContainer fieldContainer) { - ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(fieldContainer); - _resourceTypeStack.Push(resourceType); - return new PopResourceTypeOnDispose(_resourceTypeStack); + _fieldContainerStack.Push(fieldContainer); + return new PopResourceTypeOnDispose(_fieldContainerStack); } private void AssertResourceTypeStackIsEmpty() { - if (_resourceTypeStack.Count > 0) + if (_fieldContainerStack.Count > 0) { throw new InvalidOperationException("There is still a resource type in scope after parsing has completed. " + - $"Verify that {nameof(IDisposable.Dispose)}() is called on all return values of {nameof(InScopeOfResourceType)}()."); + $"Verify that {nameof(IDisposable.Dispose)}() is called on all return values of {nameof(InScopeOfContainer)}()."); } } - private sealed class PopResourceTypeOnDispose(Stack resourceTypeStack) : IDisposable + private sealed class PopResourceTypeOnDispose(Stack fieldContainerStack) : IDisposable { - private readonly Stack _resourceTypeStack = resourceTypeStack; + private readonly Stack _fieldContainerStack = fieldContainerStack; public void Dispose() { - _resourceTypeStack.Pop(); + _fieldContainerStack.Pop(); } } } diff --git a/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs index 772be38530..e594ba234a 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs @@ -53,22 +53,23 @@ protected virtual void Tokenize(string source) /// /// Parses a dot-separated path of field names into a chain of resource fields, while matching it against the specified pattern. /// - protected ResourceFieldChainExpression ParseFieldChain(FieldChainPattern pattern, FieldChainPatternMatchOptions options, ResourceType resourceType, + protected ResourceFieldChainExpression ParseFieldChain(FieldChainPattern pattern, FieldChainPatternMatchOptions options, IFieldContainer fieldContainer, string? alternativeErrorMessage) { ArgumentNullException.ThrowIfNull(pattern); - ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(fieldContainer); int startPosition = GetNextTokenPositionOrEnd(); string path = EatFieldChain(alternativeErrorMessage); - PatternMatchResult result = pattern.Match(path, resourceType, options); + PatternMatchResult result = pattern.Match(path, fieldContainer, options); if (!result.IsSuccess) { string message = result.IsFieldChainError ? result.FailureMessage - : $"Field chain on resource type '{resourceType}' failed to match the pattern: {pattern.GetDescription()}. {result.FailureMessage}"; + // TODO: Update messages to express "type" instead of "resource type". + : $"Field chain on resource type '{fieldContainer}' failed to match the pattern: {pattern.GetDescription()}. {result.FailureMessage}"; throw new QueryParseException(message, startPosition + result.FailurePosition); } diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs index 115202b138..9f871bc338 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs @@ -1,5 +1,6 @@ using System.Linq.Expressions; using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; @@ -24,7 +25,10 @@ public override Expression VisitInclude(IncludeExpression expression, QueryClaus // De-duplicate chains coming from derived relationships. HashSet propertyPaths = []; - ApplyEagerLoads(context.ResourceType.EagerLoads, null, propertyPaths); + if (context.FieldContainer is ResourceType resourceType) + { + ApplyEagerLoads(resourceType.EagerLoads, null, propertyPaths); + } foreach (ResourceFieldChainExpression chain in IncludeChainConverter.GetRelationshipChains(expression)) { diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs index 6344b91cb8..0f34f59757 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs @@ -17,9 +17,9 @@ public sealed class QueryClauseBuilderContext public Expression Source { get; } /// - /// The resource type for . + /// The resource type or parent attribute for . /// - public ResourceType ResourceType { get; } + public IFieldContainer FieldContainer { get; } /// /// The extension type to generate calls on, typically or . @@ -51,20 +51,20 @@ public sealed class QueryClauseBuilderContext /// public object? State { get; } - public QueryClauseBuilderContext(Expression source, ResourceType resourceType, Type extensionType, IReadOnlyModel entityModel, + public QueryClauseBuilderContext(Expression source, IFieldContainer fieldContainer, Type extensionType, IReadOnlyModel entityModel, LambdaScopeFactory lambdaScopeFactory, LambdaScope lambdaScope, IQueryableBuilder queryableBuilder, object? state) { ArgumentNullException.ThrowIfNull(source); - ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(fieldContainer); ArgumentNullException.ThrowIfNull(extensionType); ArgumentNullException.ThrowIfNull(entityModel); ArgumentNullException.ThrowIfNull(lambdaScopeFactory); ArgumentNullException.ThrowIfNull(lambdaScope); ArgumentNullException.ThrowIfNull(queryableBuilder); - AssertSameType(source.Type, resourceType); + AssertSameType(source.Type, fieldContainer); Source = source; - ResourceType = resourceType; + FieldContainer = fieldContainer; LambdaScope = lambdaScope; EntityModel = entityModel; ExtensionType = extensionType; @@ -73,14 +73,15 @@ public QueryClauseBuilderContext(Expression source, ResourceType resourceType, T State = state; } - private static void AssertSameType(Type sourceType, ResourceType resourceType) + private static void AssertSameType(Type sourceType, IFieldContainer fieldContainer) { Type? sourceElementType = CollectionConverter.Instance.FindCollectionElementType(sourceType); + Type containerElementType = CollectionConverter.Instance.FindCollectionElementType(fieldContainer.ClrType) ?? fieldContainer.ClrType; - if (sourceElementType != resourceType.ClrType) + if (sourceElementType != containerElementType) { throw new InvalidOperationException( - $"Internal error: Mismatch between expression type '{sourceElementType?.Name}' and resource type '{resourceType.ClrType.Name}'."); + $"Internal error: Mismatch between expression type '{sourceElementType?.Name}' and resource type '{containerElementType.Name}'."); } } @@ -88,13 +89,13 @@ public QueryClauseBuilderContext WithSource(Expression source) { ArgumentNullException.ThrowIfNull(source); - return new QueryClauseBuilderContext(source, ResourceType, ExtensionType, EntityModel, LambdaScopeFactory, LambdaScope, QueryableBuilder, State); + return new QueryClauseBuilderContext(source, FieldContainer, ExtensionType, EntityModel, LambdaScopeFactory, LambdaScope, QueryableBuilder, State); } public QueryClauseBuilderContext WithLambdaScope(LambdaScope lambdaScope) { ArgumentNullException.ThrowIfNull(lambdaScope); - return new QueryClauseBuilderContext(Source, ResourceType, ExtensionType, EntityModel, LambdaScopeFactory, lambdaScope, QueryableBuilder, State); + return new QueryClauseBuilderContext(Source, FieldContainer, ExtensionType, EntityModel, LambdaScopeFactory, lambdaScope, QueryableBuilder, State); } } diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs index 1be86e0bf5..d42f5de484 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs @@ -29,7 +29,12 @@ public virtual Expression ApplySelect(FieldSelection selection, QueryClauseBuild { ArgumentNullException.ThrowIfNull(selection); - Expression bodyInitializer = CreateLambdaBodyInitializer(selection, context.ResourceType, false, context); + if (context.FieldContainer is not ResourceType resourceType) + { + throw new InvalidOperationException("TODO: Support sparse fieldsets in compound attributes."); + } + + Expression bodyInitializer = CreateLambdaBodyInitializer(selection, resourceType, false, context); LambdaExpression lambda = Expression.Lambda(bodyInitializer, context.LambdaScope.Parameter); @@ -148,7 +153,7 @@ private static void IncludeAllScalarProperties(Type elementType, Dictionary type.ClrType == elementType); - foreach (IReadOnlyProperty property in entityType.GetProperties().Where(property => !property.IsShadowProperty())) + foreach (IReadOnlyPropertyBase property in GetEntityProperties(entityType)) { var propertySelector = new PropertySelector(property.PropertyInfo!); IncludeWritableProperty(propertySelector, propertySelectors); @@ -162,6 +167,21 @@ private static void IncludeAllScalarProperties(Type elementType, Dictionary GetEntityProperties(IReadOnlyEntityType entityType) + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:wrap_before_first_method_call true + + return entityType + .GetProperties() + .Cast() + .Concat(entityType.GetComplexProperties()) + .Where(property => !property.IsShadowProperty()); + + // @formatter:wrap_before_first_method_call restore + // @formatter:wrap_chained_method_calls restore + } + private static void IncludeFields(FieldSelectors fieldSelectors, Dictionary propertySelectors) { foreach ((ResourceFieldAttribute resourceField, QueryLayer? nextLayer) in fieldSelectors) diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs index 772b8dd18d..5df7f255e8 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs @@ -50,11 +50,16 @@ public override Expression VisitHas(HasExpression expression, QueryClauseBuilder if (expression.Filter != null) { - ResourceType resourceType = ((HasManyAttribute)expression.TargetCollection.Fields[^1]).RightType; + IFieldContainer fieldContainer = expression.TargetCollection.Fields[^1] switch + { + HasManyAttribute hasManyRelationship => hasManyRelationship.RightType, + AttrAttribute { Kind: AttrKind.CollectionOfPrimitive or AttrKind.CollectionOfCompound } attribute => attribute, + _ => throw new InvalidOperationException("Field chain must end in a to-many relationship or a collection attribute.") + }; using LambdaScope lambdaScope = context.LambdaScopeFactory.CreateScope(elementType); - var nestedContext = new QueryClauseBuilderContext(property, resourceType, typeof(Enumerable), context.EntityModel, context.LambdaScopeFactory, + var nestedContext = new QueryClauseBuilderContext(property, fieldContainer, typeof(Enumerable), context.EntityModel, context.LambdaScopeFactory, lambdaScope, context.QueryableBuilder, context.State); predicate = GetPredicateLambda(expression.Filter, nestedContext); diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs index 52e31c8dc7..1c8ac70fa6 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs @@ -7,10 +7,16 @@ namespace JsonApiDotNetCore.QueryStrings.FieldChains; [PublicAPI] public static class BuiltInPatterns { + // TODO: Requires changing pattern syntax: + // - O = to-one + // - A = non-collection attribute + // - C = collection (attribute or to-many) + // - M = (obsolete) + public static FieldChainPattern SingleField { get; } = FieldChainPattern.Parse("F"); public static FieldChainPattern ToOneChain { get; } = FieldChainPattern.Parse("O+"); - public static FieldChainPattern ToOneChainEndingInAttribute { get; } = FieldChainPattern.Parse("O*A"); - public static FieldChainPattern ToOneChainEndingInAttributeOrToOne { get; } = FieldChainPattern.Parse("O*[OA]"); - public static FieldChainPattern ToOneChainEndingInToMany { get; } = FieldChainPattern.Parse("O*M"); - public static FieldChainPattern RelationshipChainEndingInToMany { get; } = FieldChainPattern.Parse("R*M"); + public static FieldChainPattern ToOneChainEndingInAttribute { get; } = FieldChainPattern.Parse("O*A+"); + public static FieldChainPattern ToOneChainEndingInAttributeOrToOne { get; } = FieldChainPattern.Parse("O*[OA]+"); // TODO: This isn't entirely correct. + public static FieldChainPattern ToOneChainEndingInToMany { get; } = FieldChainPattern.Parse("O*[MA]+"); // TODO: This isn't entirely correct. + public static FieldChainPattern RelationshipChainEndingInToMany { get; } = FieldChainPattern.Parse("R*[MA]+"); // TODO: This isn't entirely correct. } diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs index e3c15ebdbe..675e136218 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs @@ -129,8 +129,8 @@ public static FieldChainPattern Parse(string pattern) /// /// The dot-separated chain of resource field names. /// - /// - /// The parent resource type to start matching from. + /// + /// The parent container to start matching from. /// /// /// Match options, defaults to . @@ -141,15 +141,15 @@ public static FieldChainPattern Parse(string pattern) /// /// The match result. /// - public PatternMatchResult Match(string fieldChain, ResourceType resourceType, FieldChainPatternMatchOptions options = FieldChainPatternMatchOptions.None, - ILoggerFactory? loggerFactory = null) + public PatternMatchResult Match(string fieldChain, IFieldContainer fieldContainer, + FieldChainPatternMatchOptions options = FieldChainPatternMatchOptions.None, ILoggerFactory? loggerFactory = null) { ArgumentNullException.ThrowIfNull(fieldChain); - ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(fieldContainer); ILogger logger = loggerFactory == null ? NullLogger.Instance : loggerFactory.CreateLogger(); var matcher = new PatternMatcher(this, options, logger); - return matcher.Match(fieldChain, resourceType); + return matcher.Match(fieldChain, fieldContainer); } /// diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchError.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchError.cs index 3707151560..80402bf115 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchError.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchError.cs @@ -1,5 +1,6 @@ using System.Text; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.QueryStrings.FieldChains; @@ -32,17 +33,22 @@ private MatchError(string message, int position, bool isFieldChainError) public static MatchError CreateForBrokenFieldChain(FieldChainFormatException exception) { + ArgumentNullException.ThrowIfNull(exception); + return new MatchError(exception.Message, exception.Position, true); } - public static MatchError CreateForUnknownField(int position, ResourceType? resourceType, string publicName, bool allowDerivedTypes) + public static MatchError CreateForUnknownField(int position, IFieldContainer container, string publicName, bool allowDerivedTypes) { - bool hasDerivedTypes = allowDerivedTypes && resourceType is { DirectlyDerivedTypes.Count: > 0 }; + ArgumentNullException.ThrowIfNull(container); + ArgumentNullException.ThrowIfNull(publicName); + + bool hasDerivedTypes = allowDerivedTypes && container is ResourceType { DirectlyDerivedTypes.Count: > 0 }; var builder = new MessageBuilder(); builder.WriteDoesNotExist(publicName); - builder.WriteResourceType(resourceType); + builder.WriteContainer(container); builder.WriteOrDerivedTypes(hasDerivedTypes); builder.WriteEnd(); @@ -50,18 +56,23 @@ public static MatchError CreateForUnknownField(int position, ResourceType? resou return new MatchError(message, position, true); } - public static MatchError CreateForMultipleDerivedTypes(int position, ResourceType resourceType, string publicName) + public static MatchError CreateForMultipleDerivedTypes(int position, IFieldContainer container, string publicName) { - string message = $"Field '{publicName}' is defined on multiple types that derive from resource type '{resourceType}'."; + ArgumentNullException.ThrowIfNull(container); + ArgumentNullException.ThrowIfNull(publicName); + + string message = $"Field '{publicName}' is defined on multiple types that derive from {FormatContainer(container)}."; return new MatchError(message, position, true); } - public static MatchError CreateForFieldTypeMismatch(int position, ResourceType? resourceType, FieldTypes choices) + public static MatchError CreateForFieldTypeMismatch(int position, IFieldContainer container, FieldTypes choices) { + ArgumentNullException.ThrowIfNull(container); + var builder = new MessageBuilder(); builder.WriteChoices(choices); - builder.WriteResourceType(resourceType); + builder.WriteContainer(container); builder.WriteExpected(); builder.WriteEnd(); @@ -69,7 +80,7 @@ public static MatchError CreateForFieldTypeMismatch(int position, ResourceType? return new MatchError(message, position, false); } - public static MatchError CreateForTooMuchInput(int position, ResourceType? resourceType, FieldTypes choices) + public static MatchError CreateForTooMuchInput(int position, IFieldContainer? container, FieldTypes choices) { var builder = new MessageBuilder(); @@ -79,7 +90,7 @@ public static MatchError CreateForTooMuchInput(int position, ResourceType? resou { builder.WriteOr(); builder.WriteChoices(choices); - builder.WriteResourceType(resourceType); + builder.WriteContainer(container); } builder.WriteExpected(); @@ -89,6 +100,16 @@ public static MatchError CreateForTooMuchInput(int position, ResourceType? resou return new MatchError(message, position, false); } + private static string FormatContainer(IFieldContainer container) + { + return container switch + { + ResourceType resourceType => $"resource type '{resourceType}'", + AttrAttribute attribute => $"attribute '{attribute.PublicName}'", + _ => $"{container}" + }; + } + public override string ToString() { return Message; @@ -132,11 +153,11 @@ public void WriteChoices(FieldTypes choices) } } - public void WriteResourceType(ResourceType? resourceType) + public void WriteContainer(IFieldContainer? container) { - if (resourceType != null) + if (container is ResourceType or AttrAttribute { Kind: AttrKind.Compound or AttrKind.CollectionOfCompound }) { - _builder.Append($" on resource type '{resourceType}'"); + _builder.Append($" on {FormatContainer(container)}"); } } diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs index 678850e4c4..44c200b008 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs @@ -21,9 +21,9 @@ internal sealed class MatchState public FieldChainPattern? Pattern { get; } /// - /// The resource type to find the next field on. + /// The containing resource type or parent attribute to find the next field on. /// - public ResourceType? ResourceType { get; } + public IFieldContainer Container { get; } /// /// The fields matched against this pattern segment. @@ -40,22 +40,22 @@ internal sealed class MatchState /// public MatchError? Error { get; } - private MatchState(FieldChainPattern? pattern, ResourceType? resourceType, IImmutableList fieldsMatched, + private MatchState(FieldChainPattern? pattern, IFieldContainer container, IImmutableList fieldsMatched, LinkedListNode? fieldsRemaining, MatchError? error, MatchState? parentMatch) { Pattern = pattern; - ResourceType = resourceType; + Container = container; FieldsMatched = fieldsMatched; FieldsRemaining = fieldsRemaining; Error = error; _parentMatch = parentMatch; } - public static MatchState Create(FieldChainPattern pattern, string fieldChainText, ResourceType resourceType) + public static MatchState Create(FieldChainPattern pattern, string fieldChainText, IFieldContainer fieldContainer) { ArgumentNullException.ThrowIfNull(pattern); ArgumentNullException.ThrowIfNull(fieldChainText); - ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(fieldContainer); try { @@ -63,12 +63,12 @@ public static MatchState Create(FieldChainPattern pattern, string fieldChainText IEnumerable fieldChain = parser.Parse(fieldChainText); LinkedListNode? remainingHead = new LinkedList(fieldChain).First; - return new MatchState(pattern, resourceType, ImmutableArray.Empty, remainingHead, null, null); + return new MatchState(pattern, fieldContainer, ImmutableArray.Empty, remainingHead, null, null); } catch (FieldChainFormatException exception) { var error = MatchError.CreateForBrokenFieldChain(exception); - return new MatchState(pattern, resourceType, ImmutableArray.Empty, null, error, null); + return new MatchState(pattern, fieldContainer, ImmutableArray.Empty, null, error, null); } } @@ -82,9 +82,17 @@ public MatchState SuccessMoveForwardOneField(ResourceFieldAttribute matchedValue IImmutableList fieldsMatched = FieldsMatched.Add(matchedValue); LinkedListNode? fieldsRemaining = FieldsRemaining!.Next; - ResourceType? resourceType = matchedValue is RelationshipAttribute relationship ? relationship.RightType : null; - return new MatchState(Pattern, resourceType, fieldsMatched, fieldsRemaining, null, _parentMatch); + IFieldContainer? resourceType = matchedValue is RelationshipAttribute relationship ? relationship.RightType : null; + IFieldContainer? parentAttribute = matchedValue as AttrAttribute; + IFieldContainer? container = resourceType ?? parentAttribute; + + if (container == null) + { + throw new InvalidOperationException("Internal error: Expected successful match on relationship or attribute."); + } + + return new MatchState(Pattern, container, fieldsMatched, fieldsRemaining, null, _parentMatch); } /// @@ -95,7 +103,7 @@ public MatchState SuccessMoveToNextPattern() AssertIsSuccess(this); AssertHasPattern(); - return new MatchState(Pattern!.Next, ResourceType, ImmutableArray.Empty, FieldsRemaining, null, this); + return new MatchState(Pattern!.Next, Container, ImmutableArray.Empty, FieldsRemaining, null, this); } /// @@ -103,8 +111,10 @@ public MatchState SuccessMoveToNextPattern() /// public MatchState FailureForUnknownField(string publicName, bool allowDerivedTypes) { + ArgumentNullException.ThrowIfNull(publicName); + int position = GetAbsolutePosition(true); - var error = MatchError.CreateForUnknownField(position, ResourceType, publicName, allowDerivedTypes); + var error = MatchError.CreateForUnknownField(position, Container, publicName, allowDerivedTypes); return Failure(error); } @@ -114,10 +124,10 @@ public MatchState FailureForUnknownField(string publicName, bool allowDerivedTyp /// public MatchState FailureForMultipleDerivedTypes(string publicName) { - AssertHasResourceType(); + ArgumentNullException.ThrowIfNull(publicName); int position = GetAbsolutePosition(true); - var error = MatchError.CreateForMultipleDerivedTypes(position, ResourceType!, publicName); + var error = MatchError.CreateForMultipleDerivedTypes(position, Container, publicName); return Failure(error); } @@ -129,7 +139,7 @@ public MatchState FailureForFieldTypeMismatch(FieldTypes choices, FieldTypes cho { FieldTypes allChoices = IncludeChoicesFromParentMatch(choices); int position = GetAbsolutePosition(chosenFieldType != FieldTypes.None); - var error = MatchError.CreateForFieldTypeMismatch(position, ResourceType, allChoices); + var error = MatchError.CreateForFieldTypeMismatch(position, Container, allChoices); return Failure(error); } @@ -174,14 +184,14 @@ public MatchState FailureForTooMuchInput() { FieldTypes parentChoices = IncludeChoicesFromParentMatch(FieldTypes.None); int position = GetAbsolutePosition(true); - var error = MatchError.CreateForTooMuchInput(position, _parentMatch?.ResourceType, parentChoices); + var error = MatchError.CreateForTooMuchInput(position, _parentMatch?.Container, parentChoices); return Failure(error); } private MatchState Failure(MatchError error) { - return new MatchState(Pattern, ResourceType, FieldsMatched, FieldsRemaining, error, _parentMatch); + return new MatchState(Pattern, Container, FieldsMatched, FieldsRemaining, error, _parentMatch); } private int GetAbsolutePosition(bool hasLeadingDot) @@ -264,14 +274,6 @@ private static void AssertIsSuccess(MatchState state) } } - private void AssertHasResourceType() - { - if (ResourceType == null) - { - throw new InvalidOperationException("Internal error: Resource type is unavailable."); - } - } - private void AssertHasPattern() { if (Pattern == null) diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs index 9a86542533..998d67933a 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs @@ -23,12 +23,12 @@ public PatternMatcher(FieldChainPattern pattern, FieldChainPatternMatchOptions o _allowDerivedTypes = options.HasFlag(FieldChainPatternMatchOptions.AllowDerivedTypes); } - public PatternMatchResult Match(string fieldChain, ResourceType resourceType) + public PatternMatchResult Match(string fieldChain, IFieldContainer fieldContainer) { ArgumentNullException.ThrowIfNull(fieldChain); - ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(fieldContainer); - var startState = MatchState.Create(_pattern, fieldChain, resourceType); + var startState = MatchState.Create(_pattern, fieldChain, fieldContainer); if (startState.Error != null) { @@ -155,7 +155,7 @@ private MatchState MatchField(MatchState state) { string publicName = state.FieldsRemaining.Value; - HashSet fields = LookupFields(state.ResourceType, publicName); + HashSet fields = LookupFields(state.Container, publicName); if (fields.Count == 0) { @@ -184,11 +184,11 @@ private MatchState MatchField(MatchState state) /// /// Lookup the specified field in the resource graph. /// - private HashSet LookupFields(ResourceType? resourceType, string publicName) + private HashSet LookupFields(IFieldContainer container, string publicName) { HashSet fields = []; - if (resourceType != null) + if (container is ResourceType resourceType) { if (_allowDerivedTypes) { @@ -215,6 +215,15 @@ private HashSet LookupFields(ResourceType? resourceType, } } } + else if (container is AttrAttribute { Kind: AttrKind.Compound or AttrKind.CollectionOfCompound }) + { + AttrAttribute? attribute = container.FindAttributeByPublicName(publicName); + + if (attribute != null) + { + fields.Add(attribute); + } + } return fields; } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 214987828a..f74d03b66b 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -221,10 +221,7 @@ public virtual async Task CreateAsync(TResource resourceFromRequest, TResource r await UpdateRelationshipAsync(relationship, resourceForDatabase, rightValueEvaluated, cancellationToken); } - foreach (AttrAttribute attribute in _targetedFields.Attributes) - { - attribute.SetValue(resourceForDatabase, attribute.GetValue(resourceFromRequest)); - } + ApplyTargetedAttributes(_targetedFields.Attributes, resourceFromRequest, resourceForDatabase); await _resourceDefinitionAccessor.OnWritingAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken); @@ -302,10 +299,7 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r await UpdateRelationshipAsync(relationship, resourceFromDatabase, rightValueEvaluated, cancellationToken); } - foreach (AttrAttribute attribute in _targetedFields.Attributes) - { - attribute.SetValue(resourceFromDatabase, attribute.GetValue(resourceFromRequest)); - } + ApplyTargetedAttributes(_targetedFields.Attributes, resourceFromRequest, resourceFromDatabase); await _resourceDefinitionAccessor.OnWritingAsync(resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken); @@ -648,6 +642,14 @@ private bool RequireLoadOfInverseRelationship(RelationshipAttribute relationship return false; } + private void ApplyTargetedAttributes(IReadOnlySet targets, TResource sourceResource, TResource targetResource) + { + foreach (ITargetedAttributeTree target in targets) + { + target.Apply(sourceResource, targetResource); + } + } + protected virtual async Task SaveChangesAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/JsonApiDotNetCore/Resources/ITargetedAttributeTree.cs b/src/JsonApiDotNetCore/Resources/ITargetedAttributeTree.cs new file mode 100644 index 0000000000..893bf2c387 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/ITargetedAttributeTree.cs @@ -0,0 +1,31 @@ +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Resources; + +/// +/// The data structure for an attribute targeted by a request. +/// +public interface ITargetedAttributeTree +{ + /// + /// Gets the attribute being targeted. + /// + AttrAttribute Attribute { get; } + + /// + /// Gets the set of child attributes being targeted. + /// + IReadOnlySet Children { get; } + + /// + /// Recursively applies targeted attributes by copying property values from source to target object. + /// + /// + /// The source object to copy from. + /// + /// + /// The target object to copy to. + /// + void Apply(T source, T target) + where T : notnull; +} diff --git a/src/JsonApiDotNetCore/Resources/ITargetedFields.cs b/src/JsonApiDotNetCore/Resources/ITargetedFields.cs index 32226e214d..bbcc3bf4c5 100644 --- a/src/JsonApiDotNetCore/Resources/ITargetedFields.cs +++ b/src/JsonApiDotNetCore/Resources/ITargetedFields.cs @@ -10,7 +10,7 @@ public interface ITargetedFields /// /// The set of attributes that are targeted by a request. /// - IReadOnlySet Attributes { get; } + IReadOnlySet Attributes { get; } /// /// The set of relationships that are targeted by a request. diff --git a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs index c17ce60b45..5d8405cb89 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs @@ -38,7 +38,7 @@ public void SetRequestAttributeValues(TResource resource) { ArgumentNullException.ThrowIfNull(resource); - _requestAttributeValues = CreateAttributeDictionary(resource, _targetedFields.Attributes); + _requestAttributeValues = CreateAttributeDictionary(resource, _targetedFields.Attributes.Select(target => target.Attribute)); } /// @@ -51,9 +51,11 @@ public void SetFinallyStoredAttributeValues(TResource resource) private Dictionary CreateAttributeDictionary(TResource resource, IEnumerable attributes) { + // TODO: Handle compound attributes. + var result = new Dictionary(); - foreach (AttrAttribute attribute in attributes) + foreach (AttrAttribute attribute in attributes.Where(attribute => attribute.Kind == AttrKind.Primitive)) { object? value = attribute.GetValue(resource); result.Add(attribute.PublicName, value); @@ -65,6 +67,13 @@ public void SetFinallyStoredAttributeValues(TResource resource) /// public bool HasImplicitChanges() { + // TODO: Handle compound attributes. + + if (_targetedFields.Attributes.Any(target => target.Attribute.Kind != AttrKind.Primitive)) + { + return true; + } + if (_initiallyStoredAttributeValues != null && _requestAttributeValues != null && _finallyStoredAttributeValues != null) { foreach (string key in _initiallyStoredAttributeValues.Keys) diff --git a/src/JsonApiDotNetCore/Resources/TargetedAttributeTree.cs b/src/JsonApiDotNetCore/Resources/TargetedAttributeTree.cs new file mode 100644 index 0000000000..1c7cba7ab3 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/TargetedAttributeTree.cs @@ -0,0 +1,86 @@ +using System.Diagnostics; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Resources; + +/// +[PublicAPI] +[DebuggerDisplay("{ToString(),nq}")] +public sealed class TargetedAttributeTree : ITargetedAttributeTree +{ + /// + AttrAttribute ITargetedAttributeTree.Attribute => Attribute; + + /// + IReadOnlySet ITargetedAttributeTree.Children => Children.Cast().ToHashSet().AsReadOnly(); + + /// + public AttrAttribute Attribute { get; set; } + + /// + public HashSet Children { get; } + + public TargetedAttributeTree(AttrAttribute attribute, HashSet children) + { + ArgumentNullException.ThrowIfNull(attribute); + ArgumentNullException.ThrowIfNull(children); + + Attribute = attribute; + Children = children; + } + + /// + public void Apply(T source, T target) + where T : notnull + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(target); + + if (Attribute.Kind != AttrKind.Compound) + { + object? sourceValue = Attribute.GetValue(source); + Attribute.SetValue(target, sourceValue); + } + else + { + object? subSourceInstance = Attribute.GetValue(source); + object? subTargetInstance = Attribute.GetValue(target); + + if (subSourceInstance == null) + { + Attribute.SetValue(target, null); + } + else + { + if (Children.Count > 0) + { + if (subTargetInstance == null) + { + subTargetInstance = Activator.CreateInstance(Attribute.Property.PropertyType); + Attribute.SetValue(target, subTargetInstance); + } + + if (subTargetInstance != null) + { + foreach (TargetedAttributeTree child in Children) + { + child.Apply(subSourceInstance, subTargetInstance); + } + } + } + } + } + } + + public override string ToString() + { + if (Children.Count == 0) + { + return $"{Attribute}"; + } + + // Example: contact { displayName, livingAddress { street, city }, phoneNumber } + return $"{Attribute} {{ {string.Join(", ", Children.Select(child => child.ToString()))} }}"; + } +} diff --git a/src/JsonApiDotNetCore/Resources/TargetedFields.cs b/src/JsonApiDotNetCore/Resources/TargetedFields.cs index 420058106f..cee59e22e5 100644 --- a/src/JsonApiDotNetCore/Resources/TargetedFields.cs +++ b/src/JsonApiDotNetCore/Resources/TargetedFields.cs @@ -7,10 +7,16 @@ namespace JsonApiDotNetCore.Resources; [PublicAPI] public sealed class TargetedFields : ITargetedFields { - IReadOnlySet ITargetedFields.Attributes => Attributes.AsReadOnly(); + /// + IReadOnlySet ITargetedFields.Attributes => Attributes.Cast().ToHashSet().AsReadOnly(); + + /// IReadOnlySet ITargetedFields.Relationships => Relationships.AsReadOnly(); - public HashSet Attributes { get; } = []; + /// + public HashSet Attributes { get; } = []; + + /// public HashSet Relationships { get; } = []; /// @@ -20,10 +26,24 @@ public void CopyFrom(ITargetedFields other) Clear(); - Attributes.UnionWith(other.Attributes); + CopyTargetedAttributesFrom(other.Attributes); Relationships.UnionWith(other.Relationships); } + private void CopyTargetedAttributesFrom(IReadOnlySet otherTargets) + { + foreach (ITargetedAttributeTree otherTarget in otherTargets) + { + TargetedAttributeTree thisTarget = ToMutable(otherTarget); + Attributes.Add(thisTarget); + } + } + + private static TargetedAttributeTree ToMutable(ITargetedAttributeTree target) + { + return new TargetedAttributeTree(target.Attribute, target.Children.Select(ToMutable).ToHashSet()); + } + public void Clear() { Attributes.Clear(); diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs index d20bdd5f0d..6b9fc4a2fc 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs @@ -168,8 +168,14 @@ public override ResourceObject Read(ref Utf8JsonReader reader, Type typeToConver return null; } - private Dictionary ReadAttributes(ref Utf8JsonReader reader, JsonSerializerOptions options, ResourceType resourceType) + private Dictionary? ReadAttributes(ref Utf8JsonReader reader, JsonSerializerOptions options, IFieldContainer container) { + if (reader.TokenType == JsonTokenType.Null) + { + // TODO: Produce proper error downstream (or silently ignore), add test. + return null; + } + var attributes = new Dictionary(); while (reader.Read()) @@ -192,12 +198,16 @@ public override ResourceObject Read(ref Utf8JsonReader reader, Type typeToConver string extensionNamespace = attributeName[..extensionSeparatorIndex]; string extensionName = attributeName[(extensionSeparatorIndex + 1)..]; - ValidateExtensionInAttributes(extensionNamespace, extensionName, resourceType, reader); - reader.Skip(); - continue; + // TODO: How do compound attributes affect OpenAPI? + if (container is ResourceType resourceType) + { + ValidateExtensionInAttributes(extensionNamespace, extensionName, resourceType, reader); + reader.Skip(); + continue; + } } - AttrAttribute? attribute = resourceType.FindAttributeByPublicName(attributeName); + AttrAttribute? attribute = container.FindAttributeByPublicName(attributeName); PropertyInfo? property = attribute?.Property; if (property != null) @@ -212,7 +222,9 @@ public override ResourceObject Read(ref Utf8JsonReader reader, Type typeToConver { try { - attributeValue = JsonSerializer.Deserialize(ref reader, property.PropertyType, options); + attributeValue = attribute!.Kind is AttrKind.CollectionOfPrimitive or AttrKind.CollectionOfCompound + ? ReadCollectionAttribute(ref reader, options, attribute) + : ReadAttributeValue(ref reader, options, attribute, property.PropertyType); } catch (JsonException) { @@ -242,6 +254,58 @@ public override ResourceObject Read(ref Utf8JsonReader reader, Type typeToConver throw GetEndOfStreamError(); } + private List? ReadCollectionAttribute(ref Utf8JsonReader reader, JsonSerializerOptions options, AttrAttribute attribute) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + List collection = []; + + do + { + switch (reader.TokenType) + { + case JsonTokenType.EndArray: + { + return collection; + } + case JsonTokenType.StartObject: + case JsonTokenType.PropertyName: + case JsonTokenType.String: + case JsonTokenType.Number: + case JsonTokenType.True: + case JsonTokenType.False: + case JsonTokenType.Null: + { + object? attributeValue = ReadAttributeValue(ref reader, options, attribute, attribute.ClrType); + collection.Add(attributeValue); + break; + } + } + } + while (reader.Read()); + + throw GetEndOfStreamError(); + } + + private object? ReadAttributeValue(ref Utf8JsonReader reader, JsonSerializerOptions options, AttrAttribute attribute, Type clrType) + { + if (attribute.Kind is AttrKind.Primitive) + { + return JsonSerializer.Deserialize(ref reader, clrType, options); + } + + if (attribute.Kind is AttrKind.CollectionOfPrimitive) + { + Type elementType = CollectionConverter.Instance.FindCollectionElementType(clrType) ?? clrType; + return JsonSerializer.Deserialize(ref reader, elementType, options); + } + + return ReadAttributes(ref reader, options, attribute); + } + // Currently exposed for internal use only, so we don't need a breaking change when adding support for multiple extensions. // ReSharper disable once UnusedParameter.Global private protected virtual void ValidateExtensionInAttributes(string extensionNamespace, string extensionName, ResourceType resourceType, @@ -250,8 +314,14 @@ private protected virtual void ValidateExtensionInAttributes(string extensionNam throw new JsonException($"Unsupported usage of JSON:API extension '{extensionNamespace}' in attributes."); } - private Dictionary ReadRelationships(ref Utf8JsonReader reader, JsonSerializerOptions options, ResourceType resourceType) + private Dictionary? ReadRelationships(ref Utf8JsonReader reader, JsonSerializerOptions options, ResourceType resourceType) { + if (reader.TokenType == JsonTokenType.Null) + { + // TODO: Produce proper error downstream (or silently ignore), add test. + return null; + } + var relationships = new Dictionary(); while (reader.Read()) diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInOperationsRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInOperationsRequestAdapter.cs index bb75927c92..c059bd8ff9 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInOperationsRequestAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInOperationsRequestAdapter.cs @@ -54,17 +54,14 @@ private void AssertMaxOperationsNotExceeded(IList atomic private List ConvertOperations(IEnumerable atomicOperationObjects, RequestAdapterState state) { List operations = []; - int operationIndex = 0; foreach (AtomicOperationObject? atomicOperationObject in atomicOperationObjects) { - using IDisposable _ = state.Position.PushArrayIndex(operationIndex); + using IDisposable _ = state.Position.PushArrayIndex(operations.Count); AssertObjectIsNotNull(atomicOperationObject, state); OperationContainer operation = _atomicOperationObjectAdapter.Convert(atomicOperationObject, state); operations.Add(operation); - - operationIndex++; } return operations; diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs index a488e727af..7b345da62c 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs @@ -92,17 +92,14 @@ private IEnumerable ConvertToManyRelationshipData(SingleOrManyData rightResources = []; foreach (ResourceIdentifierObject resourceIdentifierObject in data.ManyValue!) { - using IDisposable _ = state.Position.PushArrayIndex(arrayIndex); + using IDisposable _ = state.Position.PushArrayIndex(rightResources.Count); IIdentifiable rightResource = _resourceIdentifierObjectAdapter.Convert(resourceIdentifierObject, requirements, state); rightResources.Add(rightResource); - - arrayIndex++; } if (useToManyElementType) diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs index 5f9b4dd05c..c51ed49444 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs @@ -1,3 +1,4 @@ +using System.Collections; using System.Diagnostics.CodeAnalysis; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; @@ -34,49 +35,132 @@ public ResourceObjectAdapter(IResourceGraph resourceGraph, IResourceFactory reso (IIdentifiable resource, ResourceType resourceType) = ConvertResourceIdentity(resourceObject, requirements, state); - ConvertAttributes(resourceObject.Attributes, resource, resourceType, state); + ConvertAttributes(resourceObject.Attributes, resource, resourceType, state.WritableTargetedFields!.Attributes, state); ConvertRelationships(resourceObject.Relationships, resource, resourceType, state); return (resource, resourceType); } - private void ConvertAttributes(IDictionary? resourceObjectAttributes, IIdentifiable resource, ResourceType resourceType, - RequestAdapterState state) + private void ConvertAttributes(IDictionary? attributeValues, object instance, IFieldContainer container, + HashSet targets, RequestAdapterState state) { using IDisposable _ = state.Position.PushElement("attributes"); - foreach ((string attributeName, object? attributeValue) in resourceObjectAttributes.EmptyIfNull()) + foreach ((string attributeName, object? attributeValue) in attributeValues.EmptyIfNull()) { - ConvertAttribute(resource, attributeName, attributeValue, resourceType, state); + ConvertAttribute(attributeName, attributeValue, instance, container, targets, state); } } - private void ConvertAttribute(IIdentifiable resource, string attributeName, object? attributeValue, ResourceType resourceType, RequestAdapterState state) + private void ConvertAttribute(string attributeName, object? attributeValue, object instance, IFieldContainer container, + HashSet targets, RequestAdapterState state) { using IDisposable _ = state.Position.PushElement(attributeName); - AttrAttribute? attr = resourceType.FindAttributeByPublicName(attributeName); + AttrAttribute? attr = container.FindAttributeByPublicName(attributeName); if (attr == null && _options.AllowUnknownFieldsInRequestBody) { return; } - AssertIsKnownAttribute(attr, attributeName, resourceType, state); + AssertIsKnownAttribute(attr, attributeName, container, state); AssertNoInvalidAttribute(attributeValue, state); - AssertSetAttributeInCreateResourceNotBlocked(attr, resourceType, state); - AssertSetAttributeInUpdateResourceNotBlocked(attr, resourceType, state); - AssertNotReadOnly(attr, resourceType, state); + AssertSetAttributeInCreateResourceNotBlocked(attr, container, state); + AssertSetAttributeInUpdateResourceNotBlocked(attr, container, state); + AssertNotReadOnly(attr, container, state); + + if (attributeValue == null || attr.Kind == AttrKind.Primitive) + { + ConvertNullOrPrimitiveAttribute(attr, attributeValue, instance, targets); + } + else if (attr.Kind == AttrKind.Compound) + { + ConvertCompoundAttribute(attr, attributeValue, instance, targets, state); + } + else if (attr.Kind == AttrKind.CollectionOfPrimitive) + { + ConvertCollectionOfPrimitiveAttribute(attr, attributeValue, instance, targets); + } + else if (attr.Kind == AttrKind.CollectionOfCompound) + { + ConvertCollectionOfCompoundAttribute(attr, attributeValue, instance, targets, state); + } + else + { + throw new NotSupportedException($"Unknown attribute kind '{attr.Kind}'."); + } + } + + private static void ConvertNullOrPrimitiveAttribute(AttrAttribute attr, object? attributeValue, object instance, HashSet targets) + { + attr.SetValue(instance, attributeValue); + + var target = new TargetedAttributeTree(attr, []); + targets.Add(target); + } + + private void ConvertCompoundAttribute(AttrAttribute attr, object attributeValue, object instance, HashSet targets, + RequestAdapterState state) + { + object subInstance = Activator.CreateInstance(attr.Property.PropertyType)!; + attr.SetValue(instance, subInstance); + + var dictionary = (Dictionary)attributeValue; + HashSet subTargets = []; + + ConvertAttributes(dictionary, subInstance, attr, subTargets, state); + + var target = new TargetedAttributeTree(attr, subTargets); + targets.Add(target); + } + + private static void ConvertCollectionOfPrimitiveAttribute(AttrAttribute attr, object attributeValue, object instance, + HashSet targets) + { + IEnumerable typedCollection = CollectionConverter.Instance.CopyToTypedCollection((IEnumerable)attributeValue, attr.Property.PropertyType); + attr.SetValue(instance, typedCollection); + + var target = new TargetedAttributeTree(attr, []); + targets.Add(target); + } + + private void ConvertCollectionOfCompoundAttribute(AttrAttribute attr, object attributeValue, object instance, HashSet targets, + RequestAdapterState state) + { + List subInstances = []; + Type? elementType = CollectionConverter.Instance.FindCollectionElementType(attr.Property.PropertyType); + + if (elementType == null) + { + throw new ModelConversionException(state.Position, "TODO: Handle cases where array is sent instead of object, or object instead of array.", null); + } + + foreach (IDictionary? subDictionary in (IEnumerable)attributeValue) + { + using IDisposable _ = state.Position.PushArrayIndex(subInstances.Count); + + object? subInstance = subDictionary == null ? null : Activator.CreateInstance(elementType); + subInstances.Add(subInstance); + + if (subInstance != null) + { + ConvertAttributes(subDictionary, subInstance, attr, [], state); + } + } + + IEnumerable subTypedCollection = CollectionConverter.Instance.CopyToTypedCollection(subInstances, attr.Property.PropertyType); + attr.SetValue(instance, subTypedCollection); - attr.SetValue(resource, attributeValue); - state.WritableTargetedFields!.Attributes.Add(attr); + var target = new TargetedAttributeTree(attr, []); + targets.Add(target); } - private static void AssertIsKnownAttribute([NotNull] AttrAttribute? attr, string attributeName, ResourceType resourceType, RequestAdapterState state) + private static void AssertIsKnownAttribute([NotNull] AttrAttribute? attr, string attributeName, IFieldContainer container, RequestAdapterState state) { if (attr == null) { throw new ModelConversionException(state.Position, "Unknown attribute found.", - $"Attribute '{attributeName}' does not exist on resource type '{resourceType.PublicName}'."); + $"Attribute '{attributeName}' does not exist on resource type '{container.PublicName}'."); } } @@ -96,30 +180,36 @@ private static void AssertNoInvalidAttribute(object? attributeValue, RequestAdap } } - private static void AssertSetAttributeInCreateResourceNotBlocked(AttrAttribute attr, ResourceType resourceType, RequestAdapterState state) + private static void AssertSetAttributeInCreateResourceNotBlocked(AttrAttribute attr, IFieldContainer container, RequestAdapterState state) { if (state.Request.WriteOperation == WriteOperationKind.CreateResource && !attr.Capabilities.HasFlag(AttrCapabilities.AllowCreate)) { throw new ModelConversionException(state.Position, "Attribute value cannot be assigned when creating resource.", - $"The attribute '{attr.PublicName}' on resource type '{resourceType.PublicName}' cannot be assigned to."); + container is ResourceType + ? $"The attribute '{attr.PublicName}' on resource type '{container.PublicName}' cannot be assigned to." + : $"The attribute '{attr.PublicName}' on type '{container.PublicName}' cannot be assigned to."); } } - private static void AssertSetAttributeInUpdateResourceNotBlocked(AttrAttribute attr, ResourceType resourceType, RequestAdapterState state) + private static void AssertSetAttributeInUpdateResourceNotBlocked(AttrAttribute attr, IFieldContainer container, RequestAdapterState state) { if (state.Request.WriteOperation == WriteOperationKind.UpdateResource && !attr.Capabilities.HasFlag(AttrCapabilities.AllowChange)) { throw new ModelConversionException(state.Position, "Attribute value cannot be assigned when updating resource.", - $"The attribute '{attr.PublicName}' on resource type '{resourceType.PublicName}' cannot be assigned to."); + container is ResourceType + ? $"The attribute '{attr.PublicName}' on resource type '{container.PublicName}' cannot be assigned to." + : $"The attribute '{attr.PublicName}' on type '{container.PublicName}' cannot be assigned to."); } } - private static void AssertNotReadOnly(AttrAttribute attr, ResourceType resourceType, RequestAdapterState state) + private static void AssertNotReadOnly(AttrAttribute attr, IFieldContainer container, RequestAdapterState state) { if (attr.Property.SetMethod == null) { throw new ModelConversionException(state.Position, "Attribute is read-only.", - $"Attribute '{attr.PublicName}' on resource type '{resourceType.PublicName}' is read-only."); + container is ResourceType + ? $"Attribute '{attr.PublicName}' on resource type '{container.PublicName}' is read-only." + : $"Attribute '{attr.PublicName}' on type '{container.PublicName}' is read-only."); } } diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs index 80f7809255..b7132a5bac 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs @@ -245,6 +245,7 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc var attrMap = new Dictionary(resourceType.Attributes.Count); + // TODO: Descend into nested attributes? foreach (AttrAttribute attr in resourceType.Attributes) { if (!fieldSet.Contains(attr) || attr.Property.Name == nameof(Identifiable.Id)) diff --git a/test/AnnotationTests/Models/TreeNode.cs b/test/AnnotationTests/Models/TreeNode.cs index 269758fef6..6fc10e2144 100644 --- a/test/AnnotationTests/Models/TreeNode.cs +++ b/test/AnnotationTests/Models/TreeNode.cs @@ -11,7 +11,7 @@ namespace AnnotationTests.Models; GenerateControllerEndpoints = JsonApiEndpoints.Query)] public sealed class TreeNode : Identifiable { - [Attr(PublicName = "name", Capabilities = AttrCapabilities.AllowSort)] + [Attr(PublicName = "name", Capabilities = AttrCapabilities.AllowSort, IsCompound = false)] public string? DisplayName { get; set; } [HasOne(PublicName = "orders", Capabilities = HasOneCapabilities.AllowView | HasOneCapabilities.AllowInclude, Links = LinkTypes.All)] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/DateMustBeInThePastAttribute.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/DateMustBeInThePastAttribute.cs index 2a76f8ef34..4a6a1d2968 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/DateMustBeInThePastAttribute.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/DateMustBeInThePastAttribute.cs @@ -12,7 +12,8 @@ internal sealed class DateMustBeInThePastAttribute : ValidationAttribute { var targetedFields = validationContext.GetRequiredService(); - if (targetedFields.Attributes.Any(attribute => attribute.Property.Name == validationContext.MemberName)) + // TODO: Adapt for compound properties? + if (targetedFields.Attributes.Any(target => target.Attribute.Property.Name == validationContext.MemberName)) { PropertyInfo propertyInfo = validationContext.ObjectType.GetProperty(validationContext.MemberName!)!; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs index 6eb2ce3a37..1db19f703f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs @@ -186,7 +186,10 @@ [TRACE] Entering PostOperationsAsync(operations: [ }, "TargetedFields": { "Attributes": [ - "genre" + { + "Attribute": "genre", + "Children": [] + } ], "Relationships": [ "lyric", diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesAuthorizationFilter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesAuthorizationFilter.cs index a4cba72878..c7945572e2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesAuthorizationFilter.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesAuthorizationFilter.cs @@ -1,5 +1,6 @@ using System.Net; using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; @@ -87,9 +88,9 @@ public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpres { _authScopeSet.Include(relationship, Permission.Read); } - else + else if (field.Container is ResourceType resourceType) { - _authScopeSet.Include(field.Type, Permission.Read); + _authScopeSet.Include(resourceType, Permission.Read); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/Address.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/Address.cs new file mode 100644 index 0000000000..4e53e245fd --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/Address.cs @@ -0,0 +1,25 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreTests.IntegrationTests.CompoundAttributes; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Owned] +public sealed class Address +{ + [Attr] + public string Line1 { get; set; } = null!; + + [Attr] + public string? Line2 { get; set; } + + [Attr] + public string City { get; set; } = null!; + + [Attr] + public string Country { get; set; } = null!; + + [Attr] + public string PostalCode { get; set; } = null!; +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CloudAccount.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CloudAccount.cs new file mode 100644 index 0000000000..b076b971dd --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CloudAccount.cs @@ -0,0 +1,19 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.CompoundAttributes; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.CompoundAttributes")] +public sealed class CloudAccount : Identifiable +{ + [Attr(IsCompound = true)] + public Contact EmergencyContact { get; set; } = null!; + + [Attr(IsCompound = true)] + public Contact? BackupEmergencyContact { get; set; } + + [Attr(IsCompound = true)] + public ISet Contacts { get; set; } = new HashSet(); +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributeDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributeDbContext.cs new file mode 100644 index 0000000000..c8b91669f6 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributeDbContext.cs @@ -0,0 +1,31 @@ +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; + +// @formatter:wrap_chained_method_calls chop_always + +namespace JsonApiDotNetCoreTests.IntegrationTests.CompoundAttributes; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class CompoundAttributeDbContext(DbContextOptions options) + : TestableDbContext(options) +{ + public DbSet Accounts => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .OwnsOne(account => account.EmergencyContact) + .ToJson(); + + builder.Entity() + .OwnsOne(account => account.BackupEmergencyContact) + .ToJson(); + + builder.Entity() + .OwnsMany(account => account.Contacts) + .ToJson(); + + base.OnModelCreating(builder); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributeFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributeFakers.cs new file mode 100644 index 0000000000..e5fd30b5d8 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributeFakers.cs @@ -0,0 +1,51 @@ +using Bogus; +using TestBuildingBlocks; + +// @formatter:wrap_chained_method_calls chop_if_long +// @formatter:wrap_before_first_method_call true + +namespace JsonApiDotNetCoreTests.IntegrationTests.CompoundAttributes; + +internal sealed class CompoundAttributeFakers +{ + private readonly Lazy> _lazyCloudAccountFaker; + private readonly Lazy> _lazyContactFaker; + + private readonly Lazy> _lazyAddressFaker = new(() => new Faker
() + .MakeDeterministic() + .RuleFor(address => address.Line1, faker => faker.Address.StreetAddress()) + .RuleFor(address => address.Line2, faker => faker.Address.Direction()) + .RuleFor(address => address.City, faker => faker.Address.City()) + .RuleFor(address => address.Country, faker => faker.Address.Country()) + .RuleFor(address => address.PostalCode, faker => faker.Address.ZipCode())); + + private readonly Lazy> _lazyPhoneNumberFaker = new(() => new Faker() + .MakeDeterministic() + .RuleFor(phoneNumber => phoneNumber.Type, faker => faker.PickRandom()) + .RuleFor(phoneNumber => phoneNumber.CountryCode, faker => faker.Random.Int(1, 999)) + .RuleFor(phoneNumber => phoneNumber.Number, faker => faker.Phone.PhoneNumber())); + + public Faker CloudAccount => _lazyCloudAccountFaker.Value; + public Faker Contact => _lazyContactFaker.Value; + public Faker
Address => _lazyAddressFaker.Value; + public Faker PhoneNumber => _lazyPhoneNumberFaker.Value; + + public CompoundAttributeFakers() + { + _lazyCloudAccountFaker = new Lazy>(() => new Faker() + .MakeDeterministic() + .RuleFor(account => account.EmergencyContact, _ => Contact.GenerateOne()) + .RuleFor(account => account.BackupEmergencyContact, _ => Contact.GenerateOne()) + .RuleFor(account => account.Contacts, _ => Contact.GenerateSet(2))); + + _lazyContactFaker = new Lazy>(() => new Faker() + .MakeDeterministic() + .RuleFor(contact => contact.DisplayName, faker => faker.Person.FullName) + .RuleFor(contact => contact.LivingAddress, _ => Address.GenerateOne()) + .RuleFor(contact => contact.PreviousLivingAddresses, _ => Address.GenerateList(2)) + .RuleFor(contact => contact.PrimaryPhoneNumber, _ => PhoneNumber.GenerateOne()) + .RuleFor(contact => contact.SecondaryPhoneNumbers, _ => PhoneNumber.GenerateList(2)) + .RuleFor(contact => contact.EmailAddresses, faker => faker.Make(2, () => faker.Person.Email).ToArray()) + .RuleFor(contact => contact.Websites, faker => faker.Make(2, () => faker.Person.Website).ToArray())); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/Contact.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/Contact.cs new file mode 100644 index 0000000000..e20af16b84 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/Contact.cs @@ -0,0 +1,31 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreTests.IntegrationTests.CompoundAttributes; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Owned] +public sealed class Contact +{ + [Attr] + public string DisplayName { get; set; } = null!; + + [Attr(IsCompound = true)] + public Address LivingAddress { get; set; } = null!; + + [Attr(IsCompound = true)] + public IList
? PreviousLivingAddresses { get; set; } + + [Attr(IsCompound = true)] + public PhoneNumber? PrimaryPhoneNumber { get; set; } + + [Attr(IsCompound = true)] + public IList SecondaryPhoneNumbers { get; set; } = []; + + [Attr] + public string[] EmailAddresses { get; set; } = []; + + [Attr] + public string[]? Websites { get; set; } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/PatchResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/PatchResourceTests.cs new file mode 100644 index 0000000000..0ef7edc5e6 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/PatchResourceTests.cs @@ -0,0 +1,210 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.CompoundAttributes; + +public sealed class PatchResourceTests : IClassFixture, CompoundAttributeDbContext>> +{ + private readonly IntegrationTestContext, CompoundAttributeDbContext> _testContext; + private readonly CompoundAttributeFakers _fakers = new(); + + public PatchResourceTests(IntegrationTestContext, CompoundAttributeDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + } + + // TODO: Split up into individual tests. Make collection elements nullable. Add a non-exposed property, ensuring it is preserved. + [Fact] + public async Task Can_update_compound_attribute() + { + // Arrange + CloudAccount existingAccount = _fakers.CloudAccount.GenerateOne(); + existingAccount.EmergencyContact.PrimaryPhoneNumber = null; + existingAccount.EmergencyContact.PreviousLivingAddresses = null; + existingAccount.EmergencyContact.Websites = null; + + Contact newContact1 = _fakers.Contact.GenerateOne(); + Contact newContact2 = _fakers.Contact.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Accounts.Add(existingAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "cloudAccounts", + id = existingAccount.StringId, + attributes = new + { + emergencyContact = new + { + displayName = newContact1.DisplayName, + livingAddress = new + { + line1 = newContact1.LivingAddress.Line1, + line2 = (object?)null + }, + primaryPhoneNumber = new + { + type = newContact1.PrimaryPhoneNumber!.Type, + number = newContact1.PrimaryPhoneNumber!.Number + }, + secondaryPhoneNumbers = Array.Empty(), + emailAddresses = Array.Empty() + }, + backupEmergencyContact = (object?)null, + contacts = new[] + { + new + { + displayName = newContact2.DisplayName, + livingAddress = new + { + line1 = newContact2.LivingAddress.Line1, + city = newContact2.LivingAddress.City, + country = newContact2.LivingAddress.Country, + postalCode = newContact2.LivingAddress.PostalCode + }, + previousLivingAddresses = Array.Empty(), + emailAddresses = new[] + { + newContact2.EmailAddresses.ElementAt(0) + }, + websites = new[] + { + newContact2.Websites!.ElementAt(0) + } + } + } + } + } + }; + + string route = $"/cloudAccounts/{existingAccount.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => + { + resource.Id.Should().Be(existingAccount.StringId); + resource.Type.Should().Be("cloudAccounts"); + resource.Attributes.Should().HaveCount(3); + + resource.Attributes.Should().ContainKey("emergencyContact").WhoseValue.Should().BeOfType>().Subject.With(contact => + { + contact.Should().HaveCount(7); + contact.Should().ContainKey("displayName").WhoseValue.Should().Be(newContact1.DisplayName); + + contact.Should().ContainKey("livingAddress").WhoseValue.Should().BeOfType>().Subject.With(address => + { + address.Should().HaveCount(5); + address.Should().ContainKey("line1").WhoseValue.Should().Be(newContact1.LivingAddress.Line1); + address.Should().ContainKey("line2").WhoseValue.Should().BeNull(); + address.Should().ContainKey("city").WhoseValue.Should().Be(existingAccount.EmergencyContact.LivingAddress.City); + address.Should().ContainKey("country").WhoseValue.Should().Be(existingAccount.EmergencyContact.LivingAddress.Country); + address.Should().ContainKey("postalCode").WhoseValue.Should().Be(existingAccount.EmergencyContact.LivingAddress.PostalCode); + }); + + contact.Should().ContainKey("previousLivingAddresses").WhoseValue.Should().BeNull(); + + contact.Should().ContainKey("primaryPhoneNumber").WhoseValue.Should().BeOfType>().Subject.With(phoneNumber => + { + phoneNumber.Should().HaveCount(3); + phoneNumber.Should().ContainKey("type").WhoseValue.Should().Be(newContact1.PrimaryPhoneNumber!.Type); + phoneNumber.Should().ContainKey("countryCode").WhoseValue.Should().BeNull(); + phoneNumber.Should().ContainKey("number").WhoseValue.Should().Be(newContact1.PrimaryPhoneNumber!.Number); + }); + + contact.Should().ContainKey("secondaryPhoneNumbers").WhoseValue.Should().BeOfType>().Subject.Should().BeEmpty(); + contact.Should().ContainKey("emailAddresses").WhoseValue.Should().BeOfType>().Subject.Should().BeEmpty(); + contact.Should().ContainKey("websites").WhoseValue.Should().BeNull(); + }); + + resource.Attributes.Should().ContainKey("backupEmergencyContact").WhoseValue.Should().BeNull(); + + resource.Attributes.Should().ContainKey("contacts").WhoseValue.Should().BeOfType>().Subject.With(contacts => + { + contacts.Should().HaveCount(1); + + contacts[0].Should().BeOfType>().Subject.With(contact => + { + contact.Should().HaveCount(7); + contact.Should().ContainKey("displayName").WhoseValue.Should().Be(newContact2.DisplayName); + + contact.Should().ContainKey("livingAddress").WhoseValue.Should().BeOfType>().Subject.With(address => + { + address.Should().HaveCount(5); + address.Should().ContainKey("line1").WhoseValue.Should().Be(newContact2.LivingAddress.Line1); + address.Should().ContainKey("line2").WhoseValue.Should().BeNull(); + address.Should().ContainKey("city").WhoseValue.Should().Be(newContact2.LivingAddress.City); + address.Should().ContainKey("country").WhoseValue.Should().Be(newContact2.LivingAddress.Country); + address.Should().ContainKey("postalCode").WhoseValue.Should().Be(newContact2.LivingAddress.PostalCode); + }); + + contact.Should().ContainKey("previousLivingAddresses").WhoseValue.Should().BeOfType>().Subject.Should().BeEmpty(); + contact.Should().ContainKey("primaryPhoneNumber").WhoseValue.Should().BeNull(); + contact.Should().ContainKey("secondaryPhoneNumbers").WhoseValue.Should().BeOfType>().Subject.Should().BeEmpty(); + + contact.Should().ContainKey("emailAddresses").WhoseValue.Should().BeOfType>().Subject.With(emails => + { + emails.Should().HaveCount(1); + emails[0].Should().Be(newContact2.EmailAddresses.ElementAt(0)); + }); + + contact.Should().ContainKey("websites").WhoseValue.Should().BeOfType>().Subject.With(websites => + { + websites.Should().HaveCount(1); + websites[0].Should().Be(newContact2.Websites!.ElementAt(0)); + }); + }); + }); + }); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + CloudAccount accountInDatabase = await dbContext.Accounts.FirstWithIdAsync(existingAccount.Id); + + accountInDatabase.EmergencyContact.DisplayName.Should().Be(newContact1.DisplayName); + accountInDatabase.EmergencyContact.LivingAddress.Line1.Should().Be(newContact1.LivingAddress.Line1); + accountInDatabase.EmergencyContact.LivingAddress.Line2.Should().BeNull(); + accountInDatabase.EmergencyContact.LivingAddress.City.Should().Be(existingAccount.EmergencyContact.LivingAddress.City); + accountInDatabase.EmergencyContact.LivingAddress.Country.Should().Be(existingAccount.EmergencyContact.LivingAddress.Country); + accountInDatabase.EmergencyContact.LivingAddress.PostalCode.Should().Be(existingAccount.EmergencyContact.LivingAddress.PostalCode); + accountInDatabase.EmergencyContact.PrimaryPhoneNumber.Should().NotBeNull(); + accountInDatabase.EmergencyContact.PrimaryPhoneNumber.Type.Should().Be(newContact1.PrimaryPhoneNumber!.Type); + accountInDatabase.EmergencyContact.PrimaryPhoneNumber.CountryCode.Should().BeNull(); + accountInDatabase.EmergencyContact.PrimaryPhoneNumber.Number.Should().Be(newContact1.PrimaryPhoneNumber!.Number); + accountInDatabase.EmergencyContact.SecondaryPhoneNumbers.Should().BeEmpty(); + + accountInDatabase.BackupEmergencyContact.Should().BeNull(); + + accountInDatabase.Contacts.Should().HaveCount(1); + accountInDatabase.Contacts.ElementAt(0).DisplayName.Should().Be(newContact2.DisplayName); + accountInDatabase.Contacts.ElementAt(0).LivingAddress.Line1.Should().Be(newContact2.LivingAddress.Line1); + accountInDatabase.Contacts.ElementAt(0).LivingAddress.Line2.Should().BeNull(); + accountInDatabase.Contacts.ElementAt(0).LivingAddress.City.Should().Be(newContact2.LivingAddress.City); + accountInDatabase.Contacts.ElementAt(0).LivingAddress.Country.Should().Be(newContact2.LivingAddress.Country); + accountInDatabase.Contacts.ElementAt(0).LivingAddress.PostalCode.Should().Be(newContact2.LivingAddress.PostalCode); + accountInDatabase.Contacts.ElementAt(0).PreviousLivingAddresses.Should().BeEmpty(); + accountInDatabase.Contacts.ElementAt(0).PrimaryPhoneNumber.Should().BeNull(); + accountInDatabase.Contacts.ElementAt(0).SecondaryPhoneNumbers.Should().BeEmpty(); + accountInDatabase.Contacts.ElementAt(0).EmailAddresses.Should().HaveCount(1); + accountInDatabase.Contacts.ElementAt(0).EmailAddresses[0].Should().Be(newContact2.EmailAddresses.ElementAt(0)); + accountInDatabase.Contacts.ElementAt(0).Websites.Should().HaveCount(1); + accountInDatabase.Contacts.ElementAt(0).Websites![0].Should().Be(newContact2.Websites!.ElementAt(0)); + }); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/PhoneNumber.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/PhoneNumber.cs new file mode 100644 index 0000000000..50fab486ab --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/PhoneNumber.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreTests.IntegrationTests.CompoundAttributes; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Owned] +public sealed class PhoneNumber +{ + [Attr] + [Required] + public PhoneNumberType? Type { get; set; } + + [Attr] + public int? CountryCode { get; set; } + + [Attr] + public string Number { get; set; } = null!; +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/PhoneNumberType.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/PhoneNumberType.cs new file mode 100644 index 0000000000..ffac3310b6 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/PhoneNumberType.cs @@ -0,0 +1,11 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.CompoundAttributes; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public enum PhoneNumberType +{ + Home, + Work, + Mobile +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs index 99c5b30bc2..425beea532 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs @@ -27,7 +27,7 @@ private IsUpperCaseExpression ParseIsUpperCase() int chainStartPosition = GetNextTokenPositionOrEnd(); ResourceFieldChainExpression targetAttributeChain = - ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ContainerInScope, null); ResourceFieldAttribute attribute = targetAttributeChain.Fields[^1]; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs index f787319373..e8a38c987c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs @@ -37,7 +37,7 @@ private LengthExpression ParseLength() int chainStartPosition = GetNextTokenPositionOrEnd(); ResourceFieldChainExpression targetAttributeChain = - ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ContainerInScope, null); ResourceFieldAttribute attribute = targetAttributeChain.Fields[^1]; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParser.cs index 6b24d4d4bc..0a7a2a4eff 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParser.cs @@ -2,6 +2,7 @@ using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Queries.Parsing; using JsonApiDotNetCore.QueryStrings.FieldChains; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs index 11cb521a07..17faa0104c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs @@ -52,7 +52,7 @@ private SumExpression ParseSum() EatSingleCharacterToken(TokenKind.OpenParen); ResourceFieldChainExpression targetToManyRelationshipChain = ParseFieldChain(SingleToManyRelationshipChain, FieldChainPatternMatchOptions.None, - ResourceTypeInScope, "To-many relationship expected."); + ContainerInScope, "To-many relationship expected."); EatSingleCharacterToken(TokenKind.Comma); @@ -65,9 +65,10 @@ private SumExpression ParseSum() private QueryExpression ParseSumSelectorInScope(ResourceFieldChainExpression targetChain) { + // TODO: Allow collection attribute. var toManyRelationship = (HasManyAttribute)targetChain.Fields.Single(); - using IDisposable scope = InScopeOfResourceType(toManyRelationship.RightType); + using IDisposable scope = InScopeOfContainer(toManyRelationship.RightType); return ParseSumSelector(); } @@ -88,7 +89,7 @@ private QueryExpression ParseSumSelector() } ResourceFieldChainExpression fieldChain = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, - ResourceTypeInScope, null); + ContainerInScope, null); var attrAttribute = (AttrAttribute)fieldChain.Fields[^1]; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumWhereClauseBuilder.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumWhereClauseBuilder.cs index 55b9102ff4..75daec79d9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumWhereClauseBuilder.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumWhereClauseBuilder.cs @@ -22,6 +22,7 @@ private MethodCallExpression VisitSum(SumExpression expression, QueryClauseBuild { Expression collectionPropertyAccess = Visit(expression.TargetToManyRelationship, context); + // TODO: Allow collection attribute. ResourceType selectorResourceType = ((HasManyAttribute)expression.TargetToManyRelationship.Fields.Single()).RightType; using LambdaScope lambdaScope = context.LambdaScopeFactory.CreateScope(selectorResourceType.ClrType); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternInheritanceMatchTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternInheritanceMatchTests.cs index f5cd78f6ff..15cad54269 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternInheritanceMatchTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternInheritanceMatchTests.cs @@ -118,7 +118,7 @@ public void MatchSucceeds(string patternText, string resourceTypeName, string fi result.IsSuccess.Should().BeTrue(); result.FieldChain[0].PublicName.Should().Be(fieldChainText); - result.FieldChain[0].Type.ClrType.Should().Be(expectedType); + result.FieldChain[0].Container.ClrType.Should().Be(expectedType); result.FieldChain[0].Property.ReflectedType.Should().Be(expectedType); } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs index 948ce73ff3..002ad99ef4 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs @@ -20,6 +20,7 @@ public sealed class FieldChainPatternMatchTests : IDisposable private const string T = "resources"; private const string X = "unknown"; private const string A = "name"; + private const string CA = "compound.value"; private const string O = "parent"; private const string M = "children"; @@ -48,6 +49,7 @@ public FieldChainPatternMatchTests(ITestOutputHelper testOutputHelper) [InlineData("R", O)] [InlineData("R", M)] [InlineData("A", A)] + [InlineData("A+", CA)] // TODO: Add way more test cases... [InlineData("F", A)] [InlineData("F", O)] [InlineData("F", M)] @@ -415,10 +417,20 @@ private sealed class Resource : Identifiable [Attr] public string? Name { get; set; } + [Attr(IsCompound = true)] + public Compound Compound { get; set; } = new(); + [HasOne] public Resource? Parent { get; set; } [HasMany] public ISet Children { get; set; } = new HashSet(); } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class Compound + { + [Attr] + public string? Value { get; set; } + } } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs index b6d7c8b618..ce036333a7 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs @@ -85,7 +85,7 @@ public void Applies_cascading_settings_for_top_level_links(LinkTypes linksInReso Kind = EndpointKind.Relationship, Relationship = new HasOneAttribute { - Type = exampleResourceType + Container = exampleResourceType } }; @@ -358,7 +358,7 @@ public void Applies_cascading_settings_for_relationship_links(LinkTypes linksInR var relationship = new HasOneAttribute { Links = linksInRelationshipAttribute, - Type = exampleResourceType + Container = exampleResourceType }; // Act diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs index c9de843e56..1cc74d727d 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs @@ -231,7 +231,7 @@ public void Throws_When_ResourceType_Scope_Not_Disposed() // Assert action.Should().ThrowExactly().WithMessage("There is still a resource type in scope after parsing has completed. " + - "Verify that Dispose() is called on all return values of InScopeOfResourceType()."); + "Verify that Dispose() is called on all return values of InScopeOfContainer()."); } [Fact] @@ -255,7 +255,7 @@ private sealed class NotDisposingFilterParser(IResourceFactory resourceFactory) protected override FilterExpression ParseFilter() { // Forgot to dispose the return value. - _ = InScopeOfResourceType(ResourceTypeInScope); + _ = InScopeOfContainer(ContainerInScope); return base.ParseFilter(); } @@ -267,7 +267,7 @@ private sealed class ResourceTypeAccessingFilterParser(IResourceFactory resource protected override void Tokenize(string source) { // There is no resource type in scope yet. - _ = ResourceTypeInScope; + _ = ContainerInScope; base.Tokenize(source); } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs index b7a028bd6e..82e889311f 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs @@ -61,19 +61,6 @@ public void Cannot_get_value_for_null() action.Should().ThrowExactly(); } - [Fact] - public void Cannot_get_value_for_primitive_type() - { - // Arrange - var attribute = new AttrAttribute(); - - // Act - Action action = () => attribute.GetValue(1); - - // Assert - action.Should().ThrowExactly().WithMessage("Resource of type 'System.Int32' does not implement IIdentifiable."); - } - [Fact] public void Cannot_get_value_for_write_only_resource_property() { @@ -143,19 +130,6 @@ public void Cannot_set_value_for_null() action.Should().ThrowExactly(); } - [Fact] - public void Cannot_set_value_for_primitive_type() - { - // Arrange - var attribute = new AttrAttribute(); - - // Act - Action action = () => attribute.SetValue(1, "some"); - - // Assert - action.Should().ThrowExactly().WithMessage("Resource of type 'System.Int32' does not implement IIdentifiable."); - } - [Fact] public void Cannot_set_value_for_read_only_resource_property() { diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs index 8288595662..9ea1eec0ba 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs @@ -163,6 +163,20 @@ public void Cannot_add_resource_that_implements_only_non_generic_IIdentifiable() .WithMessage($"Resource type '{typeof(ResourceWithoutId)}' implements 'IIdentifiable', but not 'IIdentifiable'."); } + [Fact] + public void Cannot_add_resource_with_abstract_compound_attribute() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + Action action = () => builder.Add(typeof(ResourceWithAbstractCompoundType)); + + // Assert + action.Should().ThrowExactly().WithMessage("Resource inheritance is not supported on compound attributes."); + } + [Fact] public void Cannot_build_graph_with_missing_related_HasOne_resource() { @@ -267,6 +281,7 @@ public void Logs_warning_when_adding_non_resource_type() IReadOnlyList logLines = loggerProvider.GetLines(); logLines.Should().HaveCount(1); + // TODO: Can [NoResource] be used on compound types to suppress warning? logLines[0].Should().Be( $"[WARNING] Skipping: Type '{typeof(NonResource)}' does not implement 'IIdentifiable'. Add [NoResource] to suppress this warning."); } @@ -307,6 +322,25 @@ public void Logs_warning_when_adding_resource_without_attributes() logLines[0].Should().Be($"[WARNING] Type '{typeof(ResourceWithHasOneRelationship)}' does not contain any attributes."); } + [Fact] + public void Logs_warning_when_adding_compound_attribute_that_has_no_attributes() + { + // Arrange + var options = new JsonApiOptions(); + using var loggerProvider = new CapturingLoggerProvider(LogLevel.Warning); + using var loggerFactory = new LoggerFactory([loggerProvider]); + var builder = new ResourceGraphBuilder(options, loggerFactory); + + // Act + builder.Add(); + + // Assert + IReadOnlyList logLines = loggerProvider.GetLines(); + logLines.Should().HaveCount(1); + + logLines[0].Should().Be($"[WARNING] Type '{typeof(CompoundTypeWithoutAttributes)}' does not contain any attributes."); + } + [Fact] public void Logs_warning_on_empty_graph() { @@ -431,6 +465,24 @@ private sealed class ResourceWithoutId : IIdentifiable public string? LocalId { get; set; } } + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class ResourceWithCompoundTypeThatHasNoAttributes : Identifiable + { + [Attr(IsCompound = true)] + public CompoundTypeWithoutAttributes CompoundType { get; set; } = new(); + } + + private sealed class CompoundTypeWithoutAttributes; + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class ResourceWithAbstractCompoundType : Identifiable + { + [Attr(IsCompound = true)] + public AbstractCompoundType CompoundType { get; set; } = null!; + } + + private abstract class AbstractCompoundType; + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] private sealed class NonResource; pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy