Skip to content

fix: add defensive reflection checks with ReflectionMethodCache#57

Merged
Erlend Ellefsen (erlendellefsen) merged 1 commit into
mainfrom
fix/1.2-defensive-reflection
Jan 24, 2026
Merged

fix: add defensive reflection checks with ReflectionMethodCache#57
Erlend Ellefsen (erlendellefsen) merged 1 commit into
mainfrom
fix/1.2-defensive-reflection

Conversation

@erlendellefsen
Copy link
Copy Markdown
Collaborator

  • Centralize reflection method lookups in ReflectionMethodCache for DRY
  • Cache method lookups for performance
  • Add descriptive error messages when lookups fail (includes issue URL)
  • Add 12 regression tests

- Centralize reflection method lookups in ReflectionMethodCache for DRY
- Cache method lookups for performance
- Add descriptive error messages when lookups fail (includes issue URL)
- Add 12 regression tests
Copilot AI review requested due to automatic review settings January 24, 2026 20:48
@erlendellefsen Erlend Ellefsen (erlendellefsen) merged commit 75eb978 into main Jan 24, 2026
6 checks passed
@erlendellefsen Erlend Ellefsen (erlendellefsen) deleted the fix/1.2-defensive-reflection branch January 24, 2026 20:50
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a centralized ReflectionMethodCache to DRY up and harden reflection-based method lookups used for LINQ/EF Core expression building, and enables tests to validate these lookups.

Changes:

  • Introduces ReflectionMethodCache for common LINQ/EF Core method resolution with clearer failure messages.
  • Refactors querying components (includes/filtering/sorting) to use the centralized cache.
  • Exposes internals to the test project and adds regression tests for the cache.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
JsonApiToolkit/JsonApiToolkit.csproj Adds InternalsVisibleTo so tests can call internal cache APIs.
JsonApiToolkit/Helpers/ReflectionMethodCache.cs New helper that centralizes reflection method discovery and (partially) caches results.
JsonApiToolkit/Extensions/Querying/Includes/FilteredIncludeBuilder.cs Replaces direct reflection calls with ReflectionMethodCache lookups.
JsonApiToolkit/Extensions/Querying/Includes/EfCoreIncludeExpressions.cs Routes Include/ThenInclude method selection through the cache.
JsonApiToolkit/Extensions/Querying/Handlers/SortingHandler.cs Uses cache for Queryable ordering method discovery.
JsonApiToolkit/Extensions/Querying/Filtering/NestedPropertyNavigator.cs Uses cache for Any/Contains method discovery in expression building.
JsonApiToolkit.Tests/Helpers/ReflectionMethodCacheTests.cs Adds unit tests for the cache lookup helpers and error behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +83 to +86
typeof(Enumerable)
.GetMethods()
.FirstOrDefault(m => m.Name == "Where" && m.GetParameters().Length == 2)
?? throw new InvalidOperationException(
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetEnumerableWhere only filters by method name and parameter count. Enumerable.Where has two 2-parameter overloads (Func<TSource,bool> and Func<TSource,int,bool>), so this lookup can non-deterministically pick the index-aware overload, which will later break Expression.Call when you pass a Func<T,bool> lambda. Tighten the predicate to match the expected signature (e.g., ensure the 2nd parameter generic type definition is Func<,>).

Copilot uses AI. Check for mistakes.
Comment on lines +175 to +179
if (
!firstParamType.IsGenericType
|| firstParamType.GetGenericTypeDefinition().Name != "IIncludableQueryable`2"
)
continue;
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The IIncludableQueryable check relies on comparing GetGenericTypeDefinition().Name to a string. This is brittle and can accidentally match unrelated types or break if EF changes type names. Prefer comparing to the actual open generic type (e.g., typeof(IIncludableQueryable<,>) from Microsoft.EntityFrameworkCore.Query) or at least compare FullName.

Copilot uses AI. Check for mistakes.
Comment on lines +195 to +204
// Fallback with defensive check
var fallbackMethod =
thenIncludeMethods.FirstOrDefault()
?? throw new InvalidOperationException(
"Could not find EntityFrameworkQueryableExtensions.ThenInclude method. "
+ "Ensure Microsoft.EntityFrameworkCore is properly referenced. "
+ "Please report this issue at https://github.com/Intility/Intility.JsonApiToolkit/issues"
);

return fallbackMethod.MakeGenericMethod(entityType, previousPropertyType, newPropertyType);
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If no ThenInclude overload matches isPreviousCollection, falling back to the first ThenInclude method can select the wrong overload and cause a later, harder-to-diagnose runtime failure. It would be safer to throw an InvalidOperationException when no matching overload is found (and include isPreviousCollection + involved types in the message).

Suggested change
// Fallback with defensive check
var fallbackMethod =
thenIncludeMethods.FirstOrDefault()
?? throw new InvalidOperationException(
"Could not find EntityFrameworkQueryableExtensions.ThenInclude method. "
+ "Ensure Microsoft.EntityFrameworkCore is properly referenced. "
+ "Please report this issue at https://github.com/Intility/Intility.JsonApiToolkit/issues"
);
return fallbackMethod.MakeGenericMethod(entityType, previousPropertyType, newPropertyType);
// No matching overload found; fail fast with a clear error message instead of
// falling back to an arbitrary ThenInclude overload, which could cause subtle
// runtime failures later.
if (!thenIncludeMethods.Any())
{
throw new InvalidOperationException(
"Could not find EntityFrameworkQueryableExtensions.ThenInclude methods. "
+ "Ensure Microsoft.EntityFrameworkCore is properly referenced. "
+ "Please report this issue at https://github.com/Intility/Intility.JsonApiToolkit/issues"
);
}
throw new InvalidOperationException(
"Could not find a matching EntityFrameworkQueryableExtensions.ThenInclude overload. "
+ $"isPreviousCollection={isPreviousCollection}, "
+ $"entityType={entityType}, "
+ $"previousPropertyType={previousPropertyType}, "
+ $"newPropertyType={newPropertyType}. "
+ "This likely indicates an unexpected change in EF Core's ThenInclude overloads. "
+ "Please report this issue at https://github.com/Intility/Intility.JsonApiToolkit/issues"
);

Copilot uses AI. Check for mistakes.
Comment on lines +106 to +110
var method =
typeof(Queryable)
.GetMethods()
.FirstOrDefault(m =>
m.Name == methodName
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetQueryableOrderingMethod does a full reflection scan on every call. Since SortingHandler calls this once per sort field, consider caching the generic method definition per methodName (e.g., static fields/dictionary) to match the stated intent of ReflectionMethodCache and avoid repeated allocations.

Copilot uses AI. Check for mistakes.
Comment on lines +21 to +28
public void GetEnumerableAnyWithPredicate_IsCached()
{
// Act
var method1 = ReflectionMethodCache.GetEnumerableAnyWithPredicate(typeof(int));
var method2 = ReflectionMethodCache.GetEnumerableAnyWithPredicate(typeof(int));

// Assert - both should be the same instance (cached base method, different generic instantiation)
Assert.Equal(method1, method2);
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test/comment is misleading: it asserts two calls return equal MethodInfo, but that doesn’t actually prove the underlying reflection lookup was cached (it can still be equal even if looked up twice), and the comment mentions “different generic instantiation” even though both calls use typeof(int). Consider revising the test to validate caching behavior more directly or rename it to reflect what it truly verifies.

Suggested change
public void GetEnumerableAnyWithPredicate_IsCached()
{
// Act
var method1 = ReflectionMethodCache.GetEnumerableAnyWithPredicate(typeof(int));
var method2 = ReflectionMethodCache.GetEnumerableAnyWithPredicate(typeof(int));
// Assert - both should be the same instance (cached base method, different generic instantiation)
Assert.Equal(method1, method2);
public void GetEnumerableAnyWithPredicate_ReturnsSameMethodInfoForSameType()
{
// Act
var method1 = ReflectionMethodCache.GetEnumerableAnyWithPredicate(typeof(int));
var method2 = ReflectionMethodCache.GetEnumerableAnyWithPredicate(typeof(int));
// Assert - repeated calls with the same type return the same MethodInfo instance
Assert.Same(method1, method2);

Copilot uses AI. Check for mistakes.
Erlend Ellefsen (erlendellefsen) pushed a commit that referenced this pull request Jan 24, 2026
🤖 I have created a release *beep* *boop*
---


##
[1.3.0](Intility.JsonApiToolkit-v1.2.5...Intility.JsonApiToolkit-v1.3.0)
(2026-01-24)


### Features

* ✨ `AllowedIncludesAttribute` to whitelist allowed include paths
([6a26c29](6a26c29))
* ✨ `JsonApiOkAsync`
([d22466d](d22466d))
* ✨ `JsonApiOkAsync`
([bc26940](bc26940))
* ✨ add filtering support for included resources
([4e81c99](4e81c99))
* ✨ add support for filtering in primary resource with included r…
([0b0bb87](0b0bb87))
* ✨ add support for filtering in primary resource with included
relationships
([5c90d41](5c90d41))
* ✨ add too many reqyests exeption
([94a810d](94a810d))
* ✨ Allow collections and json columns to be mapped
([6a096bc](6a096bc))
* ✨ Code cleanup and standardization of error handling
([fec75f5](fec75f5))
* ✨ Enhance QueryHelpers with enum support and additional types
([4949c26](4949c26))
* ✨ general-purpose exception class
([0cdf9a5](0cdf9a5))
* ✨ general-purpose exception class
([e222bb4](e222bb4))
* ✨ Overall project cleanup
([c8c10f4](c8c10f4))
* ✨ Remove IncludeAsAttribute and related logic
([a1593be](a1593be))
* ✨ Support complex JsonCols
([b744b8e](b744b8e))
* 📚 add comprehensive debugging guide and enhance logging for better
troubleshooting
([0ecc0cf](0ecc0cf))
* 🚀 add ApplyFiltersOnly method for pre-aggregation filtering and add
documentation on statistics and aggregations
([b9c6546](b9c6546))
* 🚀 enhance query processing with AsSingleQuery for pagination and add
detailed logging for inclusion processing
([7824da9](7824da9))
* **errors:** add JsonApiErrorCodes and JsonApiErrors factory methods
([#60](#60))
([8531ad3](8531ad3))
* **errors:** complete refactor Phase 1 with exception filter tests a…
([#61](#61))
([e5b50be](e5b50be))


### Bug Fixes

* 🐛 single included resources are no longer ignored
([1e2e4b6](1e2e4b6))
* 🚑️ `[JsonIgnore]` not being respected
([af12b0b](af12b0b))
* 🚑️ adds support for filtering on included collection fields
([ee2eb19](ee2eb19))
* 🚑️ adds support for filtering on included collection fields
([1194fd6](1194fd6))
* 🚑️ adjust query processing order for filtered and regular includes to
enhance EF Core compatibility
([8d21509](8d21509))
* 🚑️ bracket nested filtering without the nessesary includes breaking
main filtering
([e1e5785](e1e5785))
* 🚑️ correct version number in project file to match release version
([a0a51dd](a0a51dd))
* 🚑️ error responses for forbidden includes did not include meta
information
([9610878](9610878))
* 🚑️ filtering on includes not working on 2-level
([c658327](c658327))
* 🚑️ Fixed the filtering issue for included resources.
([86cab81](86cab81))
* 🚑️ improve error messages for forbidden includes to clarify not found
status
([07ea15e](07ea15e))
* 🚑️ Initial working fix. Needs further testing and validation.
([0fa5628](0fa5628))
* 🚑️ JsonApiOk and JsonApiCreated methods not adding includes
([903eda3](903eda3))
* 🚑️ refactor querying files and fix single resource relationship issues
([962d4d4](962d4d4))
* 🚑️ reorder query processing to apply sorting before includes for
better EF Core compatibility
([20bf0d9](20bf0d9))
* 🚑️ three level nested values and collection include filters
([7f9a336](7f9a336))
* 🚑️ three level nested values and collection include filters
([044aaf0](044aaf0))
* 🚑️ use single query mode to prevent EF Core split query correlation
issues with filtered includes
([ff48615](ff48615))
* add defensive reflection checks with ReflectionMethodCache
([#57](#57))
([75eb978](75eb978))
* **mapping:** remove dead AddIncludedResourcesRecursive method
([#55](#55))
([bbc8c17](bbc8c17))
* **pagination:** guard against division by zero when Size is 0
([#59](#59))
([0863dee](0863dee))
* **parsing:** guard unsafe string parsing in filter parsers
([#58](#58))
([9fb463d](9fb463d))
* **security:** prevent log forging and add workflow permissions
([#51](#51))
([5fbbaba](5fbbaba))
* **security:** prevent log forging and update tooling
([#52](#52))
([52d73ce](52d73ce))
* support JsonPropertyName attribute and fix many-to-many collecti…
([634abff](634abff))
* support JsonPropertyName attribute and fix many-to-many collection
filtering
([6f1d961](6f1d961))


### Refactoring

* 🔨 follow ts-package renaming
([4cd1e7e](4cd1e7e))
* 🔨 optimize logging and add XML documentation
([8c14bc0](8c14bc0))
* 🔨 remove Microsoft.Identity.Abstractions package reference
([55933b7](55933b7))
* 🔨 remove the OR max count
([65107d5](65107d5))
* 🔨 remove the OR max count
([5a3aa87](5a3aa87))
* 🔨 Update JsonApiOk function and docs to align with what it actually
does
([bfe7635](bfe7635))


### Documentation

* 📝 update stats docs
([549743c](549743c))
* 📜 add too many request exeption to docs
([872ae2a](872ae2a))
* 📜 Clarify that filtering is only on main entity
([5ee3568](5ee3568))
* 📜 Update Claude.md
([88502bb](88502bb))
* 📜 update error message for forbidden includes to clarify not found
status
([95ab6ce](95ab6ce))


### Dependencies

* **actions:** bump actions/checkout from 4 to 6
([#47](#47))
([a16ab53](a16ab53))
* **actions:** bump actions/setup-dotnet from 4 to 5
([#45](#45))
([db8c0d1](db8c0d1))
* **actions:** bump actions/upload-pages-artifact from 3 to 4
([#44](#44))
([c5e35fb](c5e35fb))
* **actions:** bump github/codeql-action from 3 to 4
([#46](#46))
([4bad70c](4bad70c))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: intility-release-bot[bot] <175299729+intility-release-bot[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants