Skip to content

InvalidCastException when using ThenInclude on IReadOnlyCollection #463

Closed

Description

@rainerllera

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

  1. fiseni commented on Apr 14, 2025

    @fiseni
    Collaborator

    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.

  2. rainerllera commented on Apr 15, 2025

    @rainerllera
    Author

    Thanks for having a look @fiseni, we'll stay on v8 for now

  3. fiseni commented on Apr 16, 2025

    @fiseni
    Collaborator

    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 c is inferred as IReadOnlyCollection<Certificate> instead of Certificate. Then, only during runtime is it able to cast it to SpecialCertificate.

    Image

    But, even though it somehow works, internally in the EF, the ThenIncludeAfterReferenceMethodInfo method is called instead of ThenIncludeAfterEnumerableMethodInfo. And I'm not sure whether this is by design. It seems it works purely by chance. For example, if we use IEnumerable for the property, then the code won't even compile, there is a call ambiguity.

    Image

    Solution:

    • In these scenarios, where you have derived models and you need casting, specify the generic parameters explicitly. This will work, and in my opinion, you should always do it for these cases and not rely on the "undefined behavior".
    public class CourseWithCertificatesSpec : Specification<Course>
    {
        public CourseWithCertificatesSpec()
        {
            Query.Include(c => c.Certificates)
                 .ThenInclude<Course, Certificate, CertificateTemplate>(c => (c as SpecialCertificate)!.CertificateTemplate);
    
        }
    }
    • You may also define the navigation as a string. That would work as well.
    public class CourseWithCertificatesSpec : Specification<Course>
    {
        public CourseWithCertificatesSpec()
        {
            Query.Include("Certificates.CertificateTemplate");
        }
    }

    Let me know if this helps.

  4. self-assigned this
    on Apr 16, 2025
  5. rainerllera commented on Apr 22, 2025

    @rainerllera
    Author

    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!

  6. fiseni commented on Apr 22, 2025

    @fiseni
    Collaborator

    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.

    Image

    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.

  7. added
    questionFurther information is requested
    not plannedThis will not be worked on
    on Apr 22, 2025
  8. fiseni commented on Apr 23, 2025

    @fiseni
    Collaborator

    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.

  9. added
    bugSomething isn't working
    and removed
    not plannedThis will not be worked on
    on Apr 23, 2025
  10. added this to the v9.1.0 milestone on Apr 24, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingquestionFurther information is requested

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions