dotnet / efcore

EF Core is a modern object-database mapper for .NET. It supports LINQ queries, change tracking, updates, and schema migrations.
https://docs.microsoft.com/ef/
MIT License
13.76k stars 3.18k forks source link

AsNoTracking reduces ElementType of IQueryable<DerivedClass> to the base class #7720

Closed flensrocker closed 2 years ago

flensrocker commented 7 years ago

Storing a DbSet<DerivedClass> in an IQueryable<BaseClass> typed property and later calling AsNoTracking() on the stored queryable, the resulting query has an ElementType of BaseClass.

Background:

I have several applications, which store data about companies. Each user belongs to exactly one company. These users should only see the entities which belongs to the same company. This is solved with one master entity (the company) and several company data entities ("DetailEntity" in the example) and a claim, which contains the company-id.

If a user is an administrator, this user has no claim with a company-id and therefor should only see entities, which don't belong to companies (e.g. the company-entity itself). The administrator should only create/edit/delete companies, any other data is handled by the users of the companies.

I have library containing some general logic and base classes for these entities etc. It also provides some extensions for filtering the entities. These are used for dynamic forms generated at runtime for creating/editing data inside the company.

The application manages a list of the entities, where the lib should generate dynamic forms. As a guard for errors in configuration by the developer (besides the unit-tests of course), we have a filter, which checks if the accessed entity has an company-Id and the user, which accesses this entity, has the claim with an company-id (or if both are missing). If they don't match, an exception is thrown, so nobody should access data, which he shouldn't see (with a runtime-generated form).

The example reflects the structure of the lib and an application.

What am I doing wrong?

Steps to reproduce

Just do dotnet restore and dotnet run.

project.json

{
  "version": "1.0.0-*",
  "buildOptions": {
    "debugType": "portable",
    "emitEntryPoint": true
  },
  "dependencies": {
    "Microsoft.EntityFrameworkCore": "1.1.0",
    "Microsoft.EntityFrameworkCore.SqlServer": "1.1.0"
  },
  "frameworks": {
    "netcoreapp1.1": {
      "dependencies": {
        "Microsoft.NETCore.App": {
          "type": "platform",
          "version": "1.1.0"
        }
      },
      "imports": "dnxcore50"
    }
  }
}

Program.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Microsoft.EntityFrameworkCore;
using Lib;

namespace Lib
{
  public abstract class BaseEntity
  {
    public long Id { get; set; }
  }

  public abstract class BaseCompanyEntity : BaseEntity
  {
    public string CompanyName { get; set; }
  }

  public abstract class BaseDetailEntity<TCompanyEntity> : BaseEntity
    where TCompanyEntity : BaseCompanyEntity
  {
    public long CompanyId { get; set; }
    public TCompanyEntity Company { get; set; }
  }

  public abstract class BaseCompanyDbContext<TCompanyEntity> : DbContext
    where TCompanyEntity : BaseCompanyEntity
  {
    public DbSet<TCompanyEntity> DbsCompany { get; set; }

    public BaseCompanyDbContext()
    {
    }
  }

  public static class QueryableExtensions
  {
    public static IQueryable<BaseEntity> WhereCompany<TCompanyEntity>(this IQueryable<BaseEntity> queryable, long? companyId)
      where TCompanyEntity : BaseCompanyEntity
    {
      IQueryable<BaseDetailEntity<TCompanyEntity>> queryableDetail = null;
      if (queryable.ElementType.GetTypeInfo().IsSubclassOf(typeof(BaseDetailEntity<TCompanyEntity>)))
        queryableDetail = (IQueryable<BaseDetailEntity<TCompanyEntity>>)queryable;

      // if no companyId is given, the queryable must not of type BaseDetailEntity
      if (!companyId.HasValue && (queryableDetail == null))
        return queryable;

      // if a companyId is given, the queryable must be of type BaseDetailEntity
      if (companyId.HasValue && (queryableDetail != null))
        return queryableDetail.Where(q => q.CompanyId == companyId.Value);

      throw new Exception("access denied");
    }
  }

  public class EntityInfo
  {
    public Type EntityType { get; set; }
    public IQueryable<BaseEntity> DbSet { get; set; }

    public static EntityInfo FromQueryable(IQueryable<BaseEntity> queryable)
    {
      return new EntityInfo
      {
        EntityType = queryable.ElementType,
        DbSet = queryable
      };
    }
  }

  public interface IEntityRepository
  {
    IEnumerable<EntityInfo> Entities();
  }
}

namespace App
{
  public class CompanyEntity : Lib.BaseCompanyEntity
  {
  }

  public class DetailEntity : Lib.BaseDetailEntity<CompanyEntity>
  {
  }

  public class AppDbContext : Lib.BaseCompanyDbContext<CompanyEntity>
  {
    public DbSet<DetailEntity> DbsDetail { get; set; }

    public AppDbContext()
    {
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
      base.OnConfiguring(optionsBuilder);
      optionsBuilder.UseSqlServer("Server=(localdb)\\mssqllocaldb;Database=CompanyDetailDatabase;Trusted_Connection=True;MultipleActiveResultSets=true");
    }
  }

  public class EntityRepository : Lib.IEntityRepository
  {
    private AppDbContext _dbContext;

    public EntityRepository(AppDbContext dbContext)
    {
      _dbContext = dbContext;
    }

    public IEnumerable<Lib.EntityInfo> Entities()
    {
      yield return Lib.EntityInfo.FromQueryable(_dbContext.DbsCompany);
      yield return Lib.EntityInfo.FromQueryable(_dbContext.DbsDetail);
    }
  }

  public class Program
  {
    public static void Main(string[] args)
    {
      using (var dbContext = new AppDbContext())
      {
        dbContext.Database.EnsureDeleted();
        dbContext.Database.EnsureCreated();

        var entityRepo = new EntityRepository(dbContext);

        // actually the companyId comes from the ClaimsPrincipal provided by HttpContext.User
        // app-administrators doesn't have a claim with a companyId
        // (they are only allowed to access entites not derived from BaseDetailEntity)
        // app-users do have a claim with a companyId
        // (they are only allowed to access entites derived from BaseDetailEntity)
        //long? companyId = null;
        //Claim companyClaim = HttpContext.User.Claims.FirstOrDefault(c => c.Type == "CompanyId");
        //if (companyClaim != null)
        //  companyId = long.Parse(companyClaim.Value);

        // Mock: first user is app-administrator
        long? companyId = null;

        // at this point, only CompanyEntity is known at compile time
        // so we can't use generic methods with the actual entitytype
        foreach (var ei in entityRepo.Entities())
        {
          Console.WriteLine(string.Format("EntityType: {0}", ei.EntityType.Name));

          var q = ei.DbSet;
          Console.WriteLine(string.Format("ElementType of DbSet: {0}", q.ElementType.Name));

          q = q.WhereCompany<CompanyEntity>(companyId);
          Console.WriteLine(string.Format("ElementType after WhereCompany: {0}", q.ElementType.Name));

          q = ei.DbSet.AsNoTracking();
          Console.WriteLine(string.Format("ElementType of DbSet.AsNoTracking: {0}", q.ElementType.Name));

          q = q.WhereCompany<CompanyEntity>(companyId); // throws on DetailEntity in second loop
          Console.WriteLine(string.Format("ElementType after WhereCompany: {0}", q.ElementType.Name));

          // second entity is DetailEntity, so provide a companyId for the next loop
          // Mock: second user is app-user
          companyId = 1;
        }
      }
    }
  }
}

Further technical details

EF Core version: 1.1.0 Database Provider: Microsoft.EntityFrameworkCore.SqlServer Operating system: Windows 10 64 Bit IDE: dotnet

flensrocker commented 7 years ago
if (typeof(BaseDetailEntity<TCompanyEntity>).IsAssignableFrom(queryable.ElementType))

in WhereCompany doesn't work either.

smitpatel commented 7 years ago

@flensrocker - There is nothing wrong with AsNoTracking if you use any linq operator you will get the same result. If you replace AsNoTracking with Skip(1) above, you still get exception.

Here is the documentation of Skip -> https://msdn.microsoft.com/en-us/library/bb358985(v=vs.110).aspx All linq operators are defined as template method like Skip<TSource> therefore compiler infer the type based on the type of source enumerator. As long as you have DbSet you will have actual type of DbSet but once you apply any linq operator (or extension defined by EF like AsNoTracking) the type of the expression tree is changed to Enumerable<TSource> since the variable is defined as IQueryable<BaseEntity> TSource takes value BaseEntity. In first iteration, during WhereCompany you are returning queryable itself so the type will be same as before applying where. In second iteration, for first pass, you are casting type of queryable in your function before applying where so the type after where clause is the casted type. In the absence of that casting, Where would also return BaseEntity only.

There is nothing on EF side to be done here.

flensrocker commented 7 years ago

Thank you, I think, I understand now.

I now have a solution with an interface with a non-generic function, which returns an IQueryable<BaseEntity>, which is backed with two generic implementations for the different entity-types (BaseCompanyEntity and BaseDetailEntity).