Repository navigation
InvalidCastException when using ThenInclude on IReadOnlyCollection #463
Description
Hey all!
First of all thank you for your work in this library.
After upgrading from Ardalis.Specification v8.1.0 to v9.0.1, we started seeing an InvalidCastException when using ThenInclude on a navigation property of type IReadOnlyCollection with a cast in the lambda. We use a lot of these throughout our codebase.
This worked as expected in v8 but throws at runtime in v9.
Here's a minimal reproduction in a console app: (or you can try it in .net fiddle)
using Ardalis.Specification;
using Ardalis.Specification.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq.Expressions;
using System.Linq;
using System;
public class Certificate
{
public int Id { get; set; }
public string Name { get; set; } = "";
public Course Course { get; set; }
public int CourseId { get; set; }
}
public class SpecialCertificate : Certificate
{
public int? CertificateTemplateId { get; set; }
public CertificateTemplate CertificateTemplate { get; set; } = new();
}
public class CertificateTemplate
{
public int Id { get; set; }
public string TemplateName { get; set; } = "";
}
public class Course
{
private List<Certificate> _certs = new();
public int Id { get; set; }
//public List<Certificate> Certificates => _certs; // Replacing IReadOnlyCollection with List<T> works :)
public IReadOnlyCollection<Certificate> Certificates => _certs;
public void AddCertificate(Certificate cert) => _certs.Add(cert);
}
// ------------ DbContext -------------
public class AppDbContext : DbContext
{
public DbSet<Course> Courses => Set<Course>();
public DbSet<Certificate> Certificates => Set<Certificate>();
public DbSet<CertificateTemplate> CertificateTemplates => Set<CertificateTemplate>();
protected override void OnConfiguring(DbContextOptionsBuilder options)
=> options.UseInMemoryDatabase("TestDb");
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Course>()
.HasMany(c=>c.Certificates)
.WithOne(c=>c.Course)
.HasForeignKey(c=>c.CourseId);
modelBuilder.Entity<Certificate>()
.ToTable("Certificates");
modelBuilder.Entity<SpecialCertificate>()
.ToTable("Certificates");
}
}
// ------------ Specification -------------
public class CourseWithCertificatesSpec : Specification<Course>
{
public CourseWithCertificatesSpec()
{
Query.Include(c => c.Certificates)
.ThenInclude(c => (c as SpecialCertificate)!.CertificateTemplate); // triggers the crash in v9
}
}
// ------------ Program -------------
class Program
{
static void Main()
{
using var db = new AppDbContext();
var course = new Course();
course.AddCertificate(new SpecialCertificate { CertificateTemplate = new CertificateTemplate { TemplateName = "Test Template" } });
db.Courses.Add(course);
db.SaveChanges();
try
{
var spec = new CourseWithCertificatesSpec();
var courseWithCerts = db.Courses
.WithSpecification(spec)
.FirstOrDefault();
Console.WriteLine("Query succeeded.");
}
catch (Exception ex)
{
Console.WriteLine("Exception during query:");
Console.WriteLine(ex);
}
}
}
Running this code will throw:
Exception during query:
System.InvalidCastException: Unable to cast object of type 'System.Linq.Expressions.Expression1`1[System.Func`2[System.Collections.Generic.IReadOnlyCollection`1[Certificate],CertificateTemplate]]' to type 'System.Linq.Expressions.Expression`1[System.Func`2[Certificate,CertificateTemplate]]'.
at lambda_method108(Closure, IQueryable, LambdaExpression)
at Ardalis.Specification.EntityFrameworkCore.IncludeEvaluator.GetQuery[T](IQueryable`1 query, ISpecification`1 specification)
at Ardalis.Specification.EntityFrameworkCore.SpecificationEvaluator.GetQuery[T](IQueryable`1 query, ISpecification`1 specification, Boolean evaluateCriteriaOnly)
at Ardalis.Specification.EntityFrameworkCore.DbSetExtensions.WithSpecification[TSource](IQueryable`1 source, ISpecification`1 specification, ISpecificationEvaluator evaluator)
at Program.Main()
I think it's related with the way you determine whether to use the _thenIncludeAfterEnumerableMethodInfo in IncludeEvaluator.cs but not sure how to debug further.
Also, as noted in the code, if you replace IReadOnlyCollection with List, for example, the query works properly as it did in v8.
Let me know if you need any more info. And thanks again!
Activity
fiseni commentedon Apr 14, 2025
Hi @rainerllera,
Thanks for reporting the issue. Also, thank you for taking the time to create a reproducible sample. We rarely see that, and I do appreciate it.
Your case is a bit more complex. You have an inheritance model (TPH strategy in your case), casting for the navigation, encapsulated collection type, etc. Off the top of my head, I have no idea why, I have no immediate answer. It might be something on our side, or potentially some changes in EF9. Perhaps next week I'll try to find some time and analyze this case in more detail. Please remain on version 8 for now. I'll get back with some answer soon.
rainerllera commentedon Apr 15, 2025
Thanks for having a look @fiseni, we'll stay on v8 for now
fiseni commentedon Apr 16, 2025
Hi @rainerllera
I analyzed the issue. I think this has worked in EF by pure coincidence. I just opened an issue in the EF Core repository, you can follow it here dotnet/efcore#35959.
The whole issue is that once you have casting in the expression, the compiler is unable to properly infer the generic parameters. That will usually happen in case of collection interfaces (e.g., IReadOnlyCollection, IReadOnlyList, etc.); for concrete collections, the types will be inferred correctly. It has to do with covariance/contravariance, but I'll spare you from the details.
Let me demonstrate it for you scenario. Forget the specifications, let's use EF directly. As shown below, the
cis inferred asIReadOnlyCollection<Certificate>instead ofCertificate. Then, only during runtime is it able to cast it toSpecialCertificate.But, even though it somehow works, internally in the EF, the
ThenIncludeAfterReferenceMethodInfomethod is called instead ofThenIncludeAfterEnumerableMethodInfo. And I'm not sure whether this is by design. It seems it works purely by chance. For example, if we useIEnumerablefor the property, then the code won't even compile, there is a call ambiguity.Solution:
Let me know if this helps.
rainerllera commentedon Apr 22, 2025
Hi @fiseni, thanks for having a look! I think we'll stay at v8 for now, since this usage pattern is widely spread across our codebase and it would be time consuming to go and explicitly set the generic types. I'd assume the type inference should work properly, as it does with other collection types etc. Will watch the ef core issue and wait. Again, thank you!
fiseni commentedon Apr 22, 2025
Hi @rainerllera ,
There was a subtle bug in version 8. Funny thing, that bug actually worked in favor of your scenario :) That's why it works on v8. This PR addressed and fixed the issue (the below change is the important one). But once that was fixed, your "issue" bubbled up. Just to give you some context.
You can remain on v8 and still use EF9, so it's not a problem. But, I'd strongly suggest slowly updating these usages and defining the generic parameters explicitly (regardless of whether you're using specs or EF directly). The "cast" is a runtime operation, so whenever you have casting in the expressions, the compiler may or may not infer the parameters correctly. So, instead of compile-time inference, you'll end up with runtime inference. And as always, that might have unpredictable results.
I'm closing this issue, but feel free to re-open it in the future if you need further assistance.
fiseni commentedon Apr 23, 2025
Hey @rainerllera,
Ok, I changed my mind. Instead of waiting for EF, I made the appropriate changes and aligned with EF. If they fix it in the future, we'll update accordingly. Here are the tests, which cover all scenarios, including yours.
We just published version 9.1.0. It should work for you now.