Skip to content

Commit 6f1d961

Browse files
fix: support JsonPropertyName attribute and fix many-to-many collection filtering
1 parent ee2eb19 commit 6f1d961

5 files changed

Lines changed: 117 additions & 7 deletions

File tree

JsonApiToolkit.Tests/Extensions/QueryHelpersTests.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,4 +147,42 @@ public void GetPropertyByJsonName_WithNonExistentProperty_ReturnsNull()
147147

148148
Assert.Null(property);
149149
}
150+
151+
[Fact]
152+
public void GetPropertyByJsonName_WithJsonPropertyNameAttribute_ReturnsProperty()
153+
{
154+
var property = QueryHelpers.GetPropertyByJsonName(
155+
typeof(TestEntityWithJsonPropertyName),
156+
"customId"
157+
);
158+
159+
Assert.NotNull(property);
160+
Assert.Equal("ActualPropertyName", property.Name);
161+
}
162+
163+
[Fact]
164+
public void GetPropertyByJsonName_WithJsonPropertyNameAttribute_SnakeCase_ReturnsProperty()
165+
{
166+
var property = QueryHelpers.GetPropertyByJsonName(
167+
typeof(TestEntityWithJsonPropertyName),
168+
"display_name"
169+
);
170+
171+
Assert.NotNull(property);
172+
Assert.Equal("InternalName", property.Name);
173+
}
174+
175+
[Fact]
176+
public void GetPropertyByJsonName_PrefersPascalCaseOverJsonPropertyName()
177+
{
178+
// When a property name matches via PascalCase, it should be preferred over JsonPropertyName
179+
// This ensures backward compatibility
180+
var property = QueryHelpers.GetPropertyByJsonName(
181+
typeof(TestEntityWithJsonPropertyName),
182+
"id"
183+
);
184+
185+
Assert.NotNull(property);
186+
Assert.Equal("Id", property.Name);
187+
}
150188
}

JsonApiToolkit.Tests/Extensions/QueryableExtensionTests.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,52 @@ public void ApplyFilters_WithCollectionPropertyFilter_FiltersCorrectly()
727727
Assert.Equal(1, result[0].Id);
728728
Assert.Contains(result[0].Children, c => c.Tags.Contains("important"));
729729
}
730+
731+
[Fact]
732+
public void ApplyFilters_WithNullCollectionInMemory_ThrowsNullReferenceException()
733+
{
734+
// Note: This test documents behavior for in-memory LINQ with null collections.
735+
// In EF Core, collection navigations are never null - they're empty lists.
736+
// We removed the null check for collections because it broke EF Core many-to-many translation.
737+
// This is an acceptable trade-off since:
738+
// 1. EF Core (the primary use case) never has null collections
739+
// 2. Modern C# code initializes collections (= new()) to avoid nulls
740+
var testData = new List<TestEntity>
741+
{
742+
new TestEntity
743+
{
744+
Id = 1,
745+
Name = "Entity1",
746+
Children = null!, // Explicitly null - unusual but possible in memory
747+
},
748+
new TestEntity
749+
{
750+
Id = 2,
751+
Name = "Entity2",
752+
Children = new List<TestChildEntity>
753+
{
754+
new TestChildEntity { Id = 20, Name = "TargetChild" },
755+
},
756+
},
757+
}.AsQueryable();
758+
759+
var filterGroup = new FilterGroup
760+
{
761+
Filters = new List<FilterParameter>
762+
{
763+
new FilterParameter
764+
{
765+
Field = "children.name",
766+
Operator = FilterOperator.Eq,
767+
Value = "TargetChild",
768+
},
769+
},
770+
};
771+
772+
// In-memory LINQ with null collection will throw - this is expected
773+
// because we prioritize EF Core compatibility over in-memory null handling
774+
Assert.Throws<ArgumentNullException>(() => testData.ApplyFilters(filterGroup).ToList());
775+
}
730776
}
731777

732778
public static class TaskExtensions

JsonApiToolkit.Tests/Models/TestEntity.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using System.Text.Json.Serialization;
2+
13
namespace JsonApiToolkit.Tests.Models;
24

35
public class TestEntity
@@ -41,3 +43,14 @@ public class TestChildEntity
4143
public TestEntity? TestEntity { get; set; }
4244
public List<string> Tags { get; set; } = new();
4345
}
46+
47+
public class TestEntityWithJsonPropertyName
48+
{
49+
public int Id { get; set; }
50+
51+
[JsonPropertyName("customId")]
52+
public string? ActualPropertyName { get; set; }
53+
54+
[JsonPropertyName("display_name")]
55+
public string? InternalName { get; set; }
56+
}

JsonApiToolkit/Extensions/Querying/Filtering/NestedPropertyNavigator.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,10 @@ internal static class NestedPropertyNavigator
5151

5252
// Combine with null checks for the path so far
5353
Expression result = collectionFilter;
54-
// Add null check for the collection itself
55-
nullChecks.Add(Expression.NotEqual(current, Expression.Constant(null)));
54+
// Note: We don't add a null check for collection navigations because:
55+
// 1. Collection navigations in EF Core are never truly null in SQL
56+
// 2. Adding a null check forces MaterializeCollectionNavigation() which breaks many-to-many translation
57+
// 3. The Any() predicate handles empty collections correctly (returns false)
5658

5759
for (int j = nullChecks.Count - 1; j >= 0; j--)
5860
result = Expression.AndAlso(nullChecks[j], result);

JsonApiToolkit/Extensions/Querying/Helpers/QueryHelpers.cs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Collections.Concurrent;
22
using System.Globalization;
33
using System.Reflection;
4+
using System.Text.Json.Serialization;
45

56
namespace JsonApiToolkit.Extensions.Querying;
67

@@ -31,12 +32,22 @@ public static class QueryHelpers
3132

3233
string pascalCase = name.ToPascalCase();
3334
property = type.GetProperty(pascalCase);
35+
if (property != null)
36+
return property;
37+
38+
// Check for [JsonPropertyName] attribute
39+
property = type.GetProperties()
40+
.FirstOrDefault(p =>
41+
p.GetCustomAttribute<JsonPropertyNameAttribute>()?.Name == name
42+
);
43+
if (property != null)
44+
return property;
3445

35-
return property
36-
?? type.GetProperties()
37-
.FirstOrDefault(p =>
38-
string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)
39-
);
46+
// Fallback: case-insensitive search
47+
return type.GetProperties()
48+
.FirstOrDefault(p =>
49+
string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)
50+
);
4051
}
4152
);
4253
}

0 commit comments

Comments
 (0)