Skip to content

Commit a1593be

Browse files
feat: ✨ Remove IncludeAsAttribute and related logic
1 parent 5ee3568 commit a1593be

4 files changed

Lines changed: 113 additions & 67 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
using JsonApiToolkit.Mapping;
2+
using JsonApiToolkit.Tests.Models;
3+
4+
namespace JsonApiToolkit.Tests.Mapping;
5+
6+
public class EntityMapperTests
7+
{
8+
[Fact]
9+
public void GetAttributeProperties_IncludesForeignKeyIds()
10+
{
11+
var attributeProperties = EntityMapper.GetAttributeProperties(typeof(TestEntity));
12+
var propertyNames = attributeProperties.Select(p => p.Name).ToList();
13+
14+
// Should include foreign key ID
15+
Assert.Contains("RelatedEntityId", propertyNames);
16+
17+
// Should NOT include the primary ID
18+
Assert.DoesNotContain("Id", propertyNames);
19+
20+
// Should include other regular properties
21+
Assert.Contains("Name", propertyNames);
22+
Assert.Contains("Description", propertyNames);
23+
Assert.Contains("CreatedAt", propertyNames);
24+
Assert.Contains("IsActive", propertyNames);
25+
Assert.Contains("Status", propertyNames);
26+
}
27+
28+
[Fact]
29+
public void GetAttributeProperties_ExcludesOnlyPrimaryId()
30+
{
31+
var attributeProperties = EntityMapper.GetAttributeProperties(typeof(TestChildEntity));
32+
var propertyNames = attributeProperties.Select(p => p.Name).ToList();
33+
34+
// Should include foreign key ID
35+
Assert.Contains("TestEntityId", propertyNames);
36+
37+
// Should NOT include the primary ID
38+
Assert.DoesNotContain("Id", propertyNames);
39+
40+
// Should include other properties
41+
Assert.Contains("Name", propertyNames);
42+
}
43+
44+
[Fact]
45+
public void GetRelationshipProperties_DoesNotIncludeForeignKeyIds()
46+
{
47+
var relationshipProperties = EntityMapper.GetRelationshipProperties(typeof(TestEntity));
48+
var propertyNames = relationshipProperties.Select(p => p.Name).ToList();
49+
50+
// Should include actual relationships
51+
Assert.Contains("RelatedEntity", propertyNames);
52+
Assert.Contains("Children", propertyNames);
53+
54+
// Should NOT include foreign key IDs
55+
Assert.DoesNotContain("RelatedEntityId", propertyNames);
56+
}
57+
58+
[Fact]
59+
public void GetIdProperty_IdentifiesPrimaryId()
60+
{
61+
var idProperty = EntityMapper.GetIdProperty(typeof(TestEntity));
62+
63+
Assert.NotNull(idProperty);
64+
Assert.Equal("Id", idProperty.Name);
65+
}
66+
}

JsonApiToolkit.Tests/Mapping/JsonApiMapperTests.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,24 @@ public void ToResourceObject_MapsEntityCorrectly()
3131
Assert.Equal(true, resourceObject.Attributes["isActive"]);
3232
}
3333

34+
[Fact]
35+
public void ToResourceObject_IncludesForeignKeyIdsInAttributes()
36+
{
37+
var entity = new TestEntity
38+
{
39+
Id = 1,
40+
Name = "Test Entity",
41+
RelatedEntityId = 42,
42+
};
43+
44+
var resourceObject = JsonApiMapper.ToResourceObject(entity, "testEntities");
45+
46+
// Foreign key IDs should be included in attributes
47+
Assert.NotNull(resourceObject.Attributes);
48+
Assert.True(resourceObject.Attributes.ContainsKey("relatedEntityId"));
49+
Assert.Equal(42, resourceObject.Attributes["relatedEntityId"]);
50+
}
51+
3452
[Fact]
3553
public void ToResourceObject_WithRelationships_MapsRelationshipsCorrectly()
3654
{

JsonApiToolkit/Mapping/EntityMapper.cs

Lines changed: 27 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,6 @@ private static readonly ConcurrentDictionary<
5656
);
5757
}
5858

59-
/// <summary>
60-
/// Tag to be used on Ids that should be included as attributes in a JSON:API resource object.
61-
/// </summary>
62-
/// <remarks>
63-
/// Overrides the default behavior of excluding ID properties from attribute mapping.
64-
/// </remarks>
65-
[AttributeUsage(AttributeTargets.Property)]
66-
public class IncludeAsAttributeAttribute : Attribute;
6759

6860
/// <summary>
6961
/// Identifies the properties that should be mapped as attributes in a JSON:API resource object.
@@ -74,7 +66,7 @@ public class IncludeAsAttributeAttribute : Attribute;
7466
/// Uses a cached approach to improve performance over repeated calls. Excludes:
7567
/// <list type="bullet">
7668
/// <item>
77-
/// <description>Properties used as IDs (ending with "Id" or named "Id")</description>
69+
/// <description>The primary ID property (to avoid duplication with the resource's id field)</description>
7870
/// </item>
7971
/// <item>
8072
/// <description>Properties identified as relationships</description>
@@ -86,32 +78,29 @@ public class IncludeAsAttributeAttribute : Attribute;
8678
/// <description>Collection properties (except strings)</description>
8779
/// </item>
8880
/// </list>
89-
/// The resulting properties typically represent scalar values of the entity.
81+
/// The resulting properties typically represent scalar values of the entity, including foreign key IDs.
9082
/// </remarks>
9183
public static List<PropertyInfo> GetAttributeProperties(Type type)
9284
{
9385
return s_attributePropertyCache.GetOrAdd(
9486
type,
9587
t =>
9688
{
89+
PropertyInfo? idProperty = GetIdProperty(t);
9790
List<PropertyInfo> relationshipProps = GetRelationshipProperties(t);
9891
var relationshipNames = relationshipProps.Select(p => p.Name).ToHashSet();
9992

10093
return t.GetProperties()
10194
.Where(p =>
102-
p.GetCustomAttribute<IncludeAsAttributeAttribute>() != null
103-
|| (
104-
!p.Name.EndsWith("Id")
105-
&& p.Name != "Id"
106-
&& !relationshipNames.Contains(p.Name)
107-
&& p.CanRead
108-
&& p.GetMethod?.IsPublic == true
109-
&& (
110-
p.PropertyType == typeof(string)
111-
|| (
112-
!typeof(IEnumerable).IsAssignableFrom(p.PropertyType)
113-
|| p.PropertyType == typeof(string)
114-
)
95+
p != idProperty // Exclude only the primary ID
96+
&& !relationshipNames.Contains(p.Name)
97+
&& p.CanRead
98+
&& p.GetMethod?.IsPublic == true
99+
&& (
100+
p.PropertyType == typeof(string)
101+
|| (
102+
!typeof(IEnumerable).IsAssignableFrom(p.PropertyType)
103+
|| p.PropertyType == typeof(string)
115104
)
116105
)
117106
)
@@ -146,24 +135,21 @@ public static List<PropertyInfo> GetRelationshipProperties(Type type)
146135
{
147136
return t.GetProperties()
148137
.Where(p =>
149-
p.GetCustomAttribute<IncludeAsAttributeAttribute>() != null
150-
|| (
151-
p.CanRead
152-
&& p.GetMethod?.IsPublic == true
153-
&& (
154-
(
155-
typeof(IEnumerable).IsAssignableFrom(p.PropertyType)
156-
&& p.PropertyType != typeof(string)
157-
)
158-
|| (
159-
!p.PropertyType.IsPrimitive
160-
&& !p.PropertyType.IsValueType
161-
&& p.PropertyType != typeof(string)
162-
&& p.PropertyType != typeof(DateTime)
163-
&& p.PropertyType != typeof(DateTime?)
164-
&& p.PropertyType != typeof(Guid)
165-
&& p.PropertyType != typeof(Guid?)
166-
)
138+
p.CanRead
139+
&& p.GetMethod?.IsPublic == true
140+
&& (
141+
(
142+
typeof(IEnumerable).IsAssignableFrom(p.PropertyType)
143+
&& p.PropertyType != typeof(string)
144+
)
145+
|| (
146+
!p.PropertyType.IsPrimitive
147+
&& !p.PropertyType.IsValueType
148+
&& p.PropertyType != typeof(string)
149+
&& p.PropertyType != typeof(DateTime)
150+
&& p.PropertyType != typeof(DateTime?)
151+
&& p.PropertyType != typeof(Guid)
152+
&& p.PropertyType != typeof(Guid?)
167153
)
168154
)
169155
)

docs/docs/querying.md

Lines changed: 2 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -71,32 +71,8 @@ With this request, the toolkit will:
7171

7272
- **Filtering on included resources**: Filters only apply to the main resource type. To filter based on related entity properties, structure your query at the main entity level or use custom controller logic.
7373

74-
## Missing Attributes in JSON:API Responses
74+
## Attribute Mapping
7575

76-
When using our JSON:API implementation, you might notice that certain properties—such as `CompanyTenantId`—are not included in the API responses. This is because the default attribute mapping logic intentionally excludes any property that ends with "`Id`" (other than the primary `Id`).
77-
78-
### Default Behavior and Rationale
79-
80-
This behavior is deliberate and conforms to JSON:API best practices:
81-
- **Separation of Identity and Attributes:** The primary identifier is kept separate from the resource’s attributes. The `"id"` field uniquely identifies a resource, while attributes describe its state.
82-
- **Avoiding Redundancy:** By not duplicating identifier values as attributes, the response remains clean and unambiguous.
83-
- **Clarifying Relationships:** Properties ending in "`Id`" often indicate foreign keys or relational links. Excluding them from attributes discourages treating these as simple data values.
84-
85-
### How to Circumvent the Default Behavior
86-
87-
If your design requires that additional identifier fields be exposed as attributes—because they carry significant, non-relational context—you can override the default exclusion by using the `[IncludeAsAttribute]` attribute. For example:
88-
89-
```csharp
90-
using static JsonApiToolkit.Mapping.EntityMapper;
91-
92-
public class Company
93-
{
94-
public Guid Id { get; set; }
95-
public string CompanyName { get; set; } = string.Empty;
96-
public string CompanyCode { get; set; } = string.Empty;
97-
[IncludeAsAttribute]
98-
public Guid CompanyTenantId { get; set; }
99-
}
100-
```
76+
The primary ID property is automatically excluded from attributes since it's already present in the resource's `"id"` field.
10177

10278

0 commit comments

Comments
 (0)