Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions JsonApiToolkit.Tests/Extensions/QueryHelpersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -147,4 +147,42 @@ public void GetPropertyByJsonName_WithNonExistentProperty_ReturnsNull()

Assert.Null(property);
}

[Fact]
public void GetPropertyByJsonName_WithJsonPropertyNameAttribute_ReturnsProperty()
{
var property = QueryHelpers.GetPropertyByJsonName(
typeof(TestEntityWithJsonPropertyName),
"customId"
);

Assert.NotNull(property);
Assert.Equal("ActualPropertyName", property.Name);
}

[Fact]
public void GetPropertyByJsonName_WithJsonPropertyNameAttribute_SnakeCase_ReturnsProperty()
{
var property = QueryHelpers.GetPropertyByJsonName(
typeof(TestEntityWithJsonPropertyName),
"display_name"
);

Assert.NotNull(property);
Assert.Equal("InternalName", property.Name);
}

[Fact]
public void GetPropertyByJsonName_PrefersPascalCaseOverJsonPropertyName()
{
// When a property name matches via PascalCase, it should be preferred over JsonPropertyName
// This ensures backward compatibility
var property = QueryHelpers.GetPropertyByJsonName(
typeof(TestEntityWithJsonPropertyName),
"id"
);

Assert.NotNull(property);
Assert.Equal("Id", property.Name);
}
}
46 changes: 46 additions & 0 deletions JsonApiToolkit.Tests/Extensions/QueryableExtensionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,52 @@ public void ApplyFilters_WithCollectionPropertyFilter_FiltersCorrectly()
Assert.Equal(1, result[0].Id);
Assert.Contains(result[0].Children, c => c.Tags.Contains("important"));
}

[Fact]
public void ApplyFilters_WithNullCollectionInMemory_ThrowsNullReferenceException()
{
// Note: This test documents behavior for in-memory LINQ with null collections.
// In EF Core, collection navigations are never null - they're empty lists.
// We removed the null check for collections because it broke EF Core many-to-many translation.
// This is an acceptable trade-off since:
// 1. EF Core (the primary use case) never has null collections
// 2. Modern C# code initializes collections (= new()) to avoid nulls
var testData = new List<TestEntity>
{
new TestEntity
{
Id = 1,
Name = "Entity1",
Children = null!, // Explicitly null - unusual but possible in memory
},
new TestEntity
{
Id = 2,
Name = "Entity2",
Children = new List<TestChildEntity>
{
new TestChildEntity { Id = 20, Name = "TargetChild" },
},
},
}.AsQueryable();

var filterGroup = new FilterGroup
{
Filters = new List<FilterParameter>
{
new FilterParameter
{
Field = "children.name",
Operator = FilterOperator.Eq,
Value = "TargetChild",
},
},
};

// In-memory LINQ with null collection will throw - this is expected
// because we prioritize EF Core compatibility over in-memory null handling
Assert.Throws<ArgumentNullException>(() => testData.ApplyFilters(filterGroup).ToList());
}
}

public static class TaskExtensions
Expand Down
13 changes: 13 additions & 0 deletions JsonApiToolkit.Tests/Models/TestEntity.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Text.Json.Serialization;

namespace JsonApiToolkit.Tests.Models;

public class TestEntity
Expand Down Expand Up @@ -41,3 +43,14 @@ public class TestChildEntity
public TestEntity? TestEntity { get; set; }
public List<string> Tags { get; set; } = new();
}

public class TestEntityWithJsonPropertyName
{
public int Id { get; set; }

[JsonPropertyName("customId")]
public string? ActualPropertyName { get; set; }

[JsonPropertyName("display_name")]
public string? InternalName { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@ internal static class NestedPropertyNavigator

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

for (int j = nullChecks.Count - 1; j >= 0; j--)
result = Expression.AndAlso(nullChecks[j], result);
Expand Down
21 changes: 16 additions & 5 deletions JsonApiToolkit/Extensions/Querying/Helpers/QueryHelpers.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Collections.Concurrent;
using System.Globalization;
using System.Reflection;
using System.Text.Json.Serialization;

namespace JsonApiToolkit.Extensions.Querying;

Expand Down Expand Up @@ -31,12 +32,22 @@ public static class QueryHelpers

string pascalCase = name.ToPascalCase();
property = type.GetProperty(pascalCase);
if (property != null)
return property;

// Check for [JsonPropertyName] attribute
property = type.GetProperties()
.FirstOrDefault(p =>
p.GetCustomAttribute<JsonPropertyNameAttribute>()?.Name == name
);
if (property != null)
return property;

return property
?? type.GetProperties()
.FirstOrDefault(p =>
string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)
);
// Fallback: case-insensitive search
return type.GetProperties()
.FirstOrDefault(p =>
string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)
);
}
);
}
Expand Down