Skip to content

Commit 6a096bc

Browse files
feat: ✨ Allow collections and json columns to be mapped
1 parent a1593be commit 6a096bc

2 files changed

Lines changed: 188 additions & 9 deletions

File tree

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
using JsonApiToolkit.Mapping;
2+
using JsonApiToolkit.Models.Resources;
3+
4+
namespace JsonApiToolkit.Tests.Mapping;
5+
6+
public class JsonColumnMappingTests
7+
{
8+
public class EntityWithJsonColumns
9+
{
10+
public int Id { get; set; }
11+
public string Name { get; set; } = string.Empty;
12+
13+
// These collections have no ID properties, simulating EF Core owned entities stored as JSON
14+
public List<JsonData> JsonDataList { get; set; } = new();
15+
public ICollection<ExploitationReport> ExploitationReports { get; set; } =
16+
new List<ExploitationReport>();
17+
18+
// This has an ID property, so should be a relationship
19+
public List<RelatedEntity> RelatedEntities { get; set; } = new();
20+
}
21+
22+
public class JsonData
23+
{
24+
public string Type { get; set; } = string.Empty;
25+
public string Value { get; set; } = string.Empty;
26+
public DateTime Timestamp { get; set; }
27+
// No Id property - this is owned entity stored as JSON
28+
}
29+
30+
public class ExploitationReport
31+
{
32+
public string Description { get; set; } = string.Empty;
33+
public string Severity { get; set; } = string.Empty;
34+
// No Id property - this is owned entity stored as JSON
35+
}
36+
37+
public class RelatedEntity
38+
{
39+
public int Id { get; set; }
40+
public string Name { get; set; } = string.Empty;
41+
}
42+
43+
[Fact]
44+
public void GetAttributeProperties_IncludesJsonColumns()
45+
{
46+
// Arrange
47+
Type entityType = typeof(EntityWithJsonColumns);
48+
49+
// Act
50+
var attributeProperties = EntityMapper.GetAttributeProperties(entityType);
51+
var attributeNames = attributeProperties.Select(p => p.Name).ToList();
52+
53+
// Assert
54+
Assert.Contains("Name", attributeNames);
55+
Assert.Contains("JsonDataList", attributeNames); // Should be included as attribute (no IDs)
56+
Assert.Contains("ExploitationReports", attributeNames); // Should be included as attribute (no IDs)
57+
Assert.DoesNotContain("RelatedEntities", attributeNames); // Should NOT be attribute (has IDs)
58+
}
59+
60+
[Fact]
61+
public void GetRelationshipProperties_ExcludesJsonColumns()
62+
{
63+
// Arrange
64+
Type entityType = typeof(EntityWithJsonColumns);
65+
66+
// Act
67+
var relationshipProperties = EntityMapper.GetRelationshipProperties(entityType);
68+
var relationshipNames = relationshipProperties.Select(p => p.Name).ToList();
69+
70+
// Assert
71+
Assert.DoesNotContain("JsonDataList", relationshipNames); // Should NOT be relationship (no IDs)
72+
Assert.DoesNotContain("ExploitationReports", relationshipNames); // Should NOT be relationship (no IDs)
73+
Assert.Contains("RelatedEntities", relationshipNames); // Should be relationship (has IDs)
74+
}
75+
76+
[Fact]
77+
public void ToResourceObject_MapsJsonColumnsAsAttributes()
78+
{
79+
// Arrange
80+
var entity = new EntityWithJsonColumns
81+
{
82+
Id = 1,
83+
Name = "Test Entity",
84+
JsonDataList = new List<JsonData>
85+
{
86+
new()
87+
{
88+
Type = "warning",
89+
Value = "test warning",
90+
Timestamp = DateTime.Now,
91+
},
92+
new()
93+
{
94+
Type = "error",
95+
Value = "test error",
96+
Timestamp = DateTime.Now,
97+
},
98+
},
99+
ExploitationReports = new List<ExploitationReport>
100+
{
101+
new() { Description = "CVE-2024-1234", Severity = "High" },
102+
},
103+
RelatedEntities = new List<RelatedEntity>
104+
{
105+
new() { Id = 10, Name = "Related 1" },
106+
},
107+
};
108+
109+
// Act
110+
ResourceObject resource = JsonApiMapper.ToResourceObject(entity, "entityWithJsonColumns");
111+
112+
// Assert
113+
Assert.NotNull(resource.Attributes);
114+
Assert.Equal("Test Entity", resource.Attributes["name"]);
115+
116+
// JSON columns should be in attributes
117+
Assert.True(resource.Attributes.ContainsKey("jsonDataList"));
118+
Assert.True(resource.Attributes.ContainsKey("exploitationReports"));
119+
120+
// Verify the JSON data is preserved
121+
var jsonDataList = resource.Attributes["jsonDataList"] as List<JsonData>;
122+
Assert.NotNull(jsonDataList);
123+
Assert.Equal(2, jsonDataList.Count);
124+
125+
var exploitationReports =
126+
resource.Attributes["exploitationReports"] as ICollection<ExploitationReport>;
127+
Assert.NotNull(exploitationReports);
128+
Assert.Single(exploitationReports);
129+
130+
// RelatedEntities should NOT be in attributes (it's a relationship)
131+
Assert.False(resource.Attributes.ContainsKey("relatedEntities"));
132+
}
133+
}

JsonApiToolkit/Mapping/EntityMapper.cs

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

59-
6059
/// <summary>
6160
/// Identifies the properties that should be mapped as attributes in a JSON:API resource object.
6261
/// </summary>
@@ -93,16 +92,9 @@ public static List<PropertyInfo> GetAttributeProperties(Type type)
9392
return t.GetProperties()
9493
.Where(p =>
9594
p != idProperty // Exclude only the primary ID
96-
&& !relationshipNames.Contains(p.Name)
95+
&& !relationshipNames.Contains(p.Name) // Exclude properties identified as relationships
9796
&& p.CanRead
9897
&& p.GetMethod?.IsPublic == true
99-
&& (
100-
p.PropertyType == typeof(string)
101-
|| (
102-
!typeof(IEnumerable).IsAssignableFrom(p.PropertyType)
103-
|| p.PropertyType == typeof(string)
104-
)
105-
)
10698
)
10799
.ToList();
108100
}
@@ -126,6 +118,7 @@ public static List<PropertyInfo> GetAttributeProperties(Type type)
126118
/// </item>
127119
/// </list>
128120
/// <para>Excludes common value types like string, DateTime, and Guid.</para>
121+
/// <para>Collections of entities without ID properties (e.g., EF Core owned entities stored as JSON) are excluded and treated as attributes instead.</para>
129122
/// </remarks>
130123
public static List<PropertyInfo> GetRelationshipProperties(Type type)
131124
{
@@ -141,6 +134,7 @@ public static List<PropertyInfo> GetRelationshipProperties(Type type)
141134
(
142135
typeof(IEnumerable).IsAssignableFrom(p.PropertyType)
143136
&& p.PropertyType != typeof(string)
137+
&& HasIdProperty(GetCollectionElementType(p.PropertyType)) // Only include collections where items have IDs
144138
)
145139
|| (
146140
!p.PropertyType.IsPrimitive
@@ -150,6 +144,7 @@ public static List<PropertyInfo> GetRelationshipProperties(Type type)
150144
&& p.PropertyType != typeof(DateTime?)
151145
&& p.PropertyType != typeof(Guid)
152146
&& p.PropertyType != typeof(Guid?)
147+
&& !typeof(IEnumerable).IsAssignableFrom(p.PropertyType) // Exclude collections from complex object relationships
153148
)
154149
)
155150
)
@@ -172,4 +167,55 @@ public static string GetResourceType(Type type)
172167
string name = type.Name;
173168
return name.ToCamelCase();
174169
}
170+
171+
/// <summary>
172+
/// Checks if a type has an ID property.
173+
/// </summary>
174+
/// <param name="type">The type to check</param>
175+
/// <returns>True if the type has an ID property, false otherwise</returns>
176+
private static bool HasIdProperty(Type? type)
177+
{
178+
if (type == null)
179+
return false;
180+
return GetIdProperty(type) != null;
181+
}
182+
183+
/// <summary>
184+
/// Gets the element type of a collection.
185+
/// </summary>
186+
/// <param name="collectionType">The collection type</param>
187+
/// <returns>The element type, or null if not a collection</returns>
188+
private static Type? GetCollectionElementType(Type collectionType)
189+
{
190+
// String is not considered a collection for our purposes
191+
if (collectionType == typeof(string))
192+
{
193+
return null;
194+
}
195+
196+
// Check if it's a generic collection
197+
if (collectionType.IsGenericType)
198+
{
199+
Type[] genericArgs = collectionType.GetGenericArguments();
200+
if (genericArgs.Length == 1)
201+
{
202+
return genericArgs[0];
203+
}
204+
}
205+
206+
// Check if it implements IEnumerable<T>
207+
Type? enumerable = collectionType
208+
.GetInterfaces()
209+
.FirstOrDefault(i =>
210+
i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>)
211+
);
212+
213+
if (enumerable != null)
214+
{
215+
return enumerable.GetGenericArguments()[0];
216+
}
217+
218+
// For non-generic collections, we can't determine the element type
219+
return null;
220+
}
175221
}

0 commit comments

Comments
 (0)