EFCore中提前將表達式轉換成Sql並返回IQueryable 類型的數據避免轉換失敗


背景

  在使用EFCore中碰到最多的一類問題就是自己寫的Linq語句最終在轉換為SQL語句的時候失敗,特別是嵌套子查詢的時候經常可能不符合預期,這個時候該怎么解決這個問題,這個是在進行SQL查詢的時候經常碰到下面的這個錯誤:

System.InvalidOperationException: Error generated for warning 'Microsoft.EntityFrameworkCore.Query.QueryClientEvaluationWarning: 
The LINQ expression 'XXX'  could not be translated and will be evaluated locally.'. 
This exception can be suppressed or logged by passing event ID 'RelationalEventId.QueryClientEvaluationWarning'
 to the 'ConfigureWarnings' method in 'DbContext.OnConfiguring' or 'AddDbContext'.

  這個在開發的時候經常會遇到,如果忽略這個錯誤發到線上環境的時候有可能會將之前的Linq語句直接查詢數據到內存中進行處理,這個在很多情況下會造成幾乎災難的后果,所以在開發的時候我們要通過一定的手段提前將這個問題在調試的時候就解除掉,經過我們團隊長期的摸索最后找到了一些EFCore的一些規律和技巧,在后面的部分我們將會就這個問題進行分析。

案例分析

  在下面的一個代碼片段中,freeMaintainFees 是我們查詢的一個IQueryable<T>的對象,這個是一個簡單的兩個表join后分組並求和的過程,但是這個返回的結果會作為一個子查詢並作為查詢workItemAndMaterialQuery的一個部分,這個時候在EFCore中經常會出現之前報的那個System.InvalidOperationException的異常,就是由於整個查詢的結果過於復雜所以造成EFCore在轉換SQL的時候經常失敗,這個就有了我們之前的那個結論:提前將表達式轉換成Sql並返回IQueryable<T> 類型的數據避免轉換失敗,那我們怎么實現這樣的一個效果呢?如果你細心觀察這個方法你就會發現我們用了一個freeMaintainFees = _repairContractRepository.GetAllByConvertSql(freeMaintainFees)的中間方法,那這個里面核心就是GetAllByConvertSql的方法,那么這個方法到底是怎么實現的呢?

        private IQueryable<GetWorkItemAndMaterialDetailOutput> GetEmployeePerformanceWorkItemAndMaterialDetailQuery(GetEmployeePerformanceDetailsInput input) {
            // 服務顧問結清費用:各服務顧問非作廢的委托書,對應的維修結算單的付款方式為已經結清的實收金額費用
            //修理工完工費用:非作廢的委托書中各維修項目上的修理工對應的委托書中實收金額費用
            var repairFees = _repairContractRepository.GetAll()
                .Where(r => r.ContractType == ContractType.維修)
                .Select(r => new { r.Id, r.Status, TotalFeeAfter = r.TotalFeeAfter ?? default });

            //免保費:合同類型 = 索賠 且 委托書.維修類別 = 首次保養、VIP保養、定期保養的委托書,對應非作廢的維修索賠單.費用合計
            var freeMaintainFees = from claim in _claimRepository.GetAll().Where(c => c.Status != ClaimStatus.作廢 && c.Type == ClaimType.保養)
                                   join contract in _repairContractRepository.GetAll()
                                        .Include(r => r.RepairContractWorkItems)
                                        .Where(r => r.ContractType == ContractType.索賠 && r.RepairContractWorkItems.Any(w => w.RepairType == RepairType.VIP保養
                                                     || w.RepairType == RepairType.首次保養 || w.RepairType == RepairType.定期保養))
                                        on claim.RepairContractId equals contract.Id
                                   group claim.TotalFee by claim.RepairContractId into grouped
                                   select new FreeMaintainFeeModel {
                                       RepairContractId = grouped.Key,
                                       TotalFee = grouped.Sum()
                                   };

            freeMaintainFees = _repairContractRepository.GetAllByConvertSql(freeMaintainFees);           

            var dealerId = SdtSession.TenantId.GetValueOrDefault();
            var workItemAndMaterialQuery = from repairContract in _repairContractRepository.GetAll().Where(x => x.ServiceAdvisorId == input.EmployeeId)
                                                                .WhereIf(input.BeginTime.HasValue, x => input.BeginTime <= x.CreateTime)
                                                                .WhereIf(input.EndTime.HasValue, x => x.CreateTime <= input.EndTime)
                                                                .Where(r => r.DealerId == dealerId && r.Status == RepairContractStatus.已結算)
                                               join workItem in _repairContractWorkItemRepository.GetAll()
                                                   on repairContract.Id equals workItem.RepairContractId
                                               join material in _repairContractMaterialRepository.GetAll()
                                                   on workItem.Id equals material.RepairContractWorkItemId into materials
                                               from material in materials.DefaultIfEmpty()
                                               join fMaintainFee in freeMaintainFees
                                                    on repairContract.Id equals fMaintainFee.RepairContractId into fMaintainFees
                                               from fMaintainFee in fMaintainFees.DefaultIfEmpty()                                              
                                               join repair in repairFees.Where(r => r.Status == RepairContractStatus.已結算)
                                                    on repairContract.Id equals repair.Id into repairs
                                               from repair in repairs.DefaultIfEmpty()
                                               select new GetWorkItemAndMaterialDetailOutput {
                                                   RepairContractCode = repairContract.Code,
                                                   LicensePlate = repairContract.LicensePlate,
                                                   Vin = repairContract.Vin,
                                                   BrandName = repairContract.BrandName,
                                                   ProductCode = repairContract.ProductCode,
                                                   ProductCategoryCode = repairContract.ProductCategoryCode,
                                                   ServiceAdvisorName = repairContract.ServiceAdvisorName,
                                                   WorkerName = workItem.Id == null ? "" : workItem.WorkerName,
                                                   WorkItemCode = workItem.Id == null ? "" : workItem.WorkItemCode,
                                                   WorkItemName = workItem.Id == null ? "" : workItem.WorkItemName,
                                                   RepairType = workItem.Id == null ? new RepairType() : workItem.RepairType,
                                                   LaborFeeAfter = repairContract.ContractType == ContractType.索賠
                                                       ? workItem.Id == null ? 0 : workItem.LaborHour * workItem.LaborPrice
                                                       : workItem.Id == null ? 0 : workItem.LaborFeeAfter == null ? 0 : workItem.LaborFeeAfter,
                                                   PartCode = material.Id == null ? "" : material.NewPartCode,
                                                   PartName = material.Id == null ? "" : material.NewPartName,
                                                   Quantity = material.Id == null ? default : material.Quantity,
                                                   Price = material.Id == null ? default : material.Price,
                                                   MaterialFee = material.Id == null ? default : material.Quantity * material.Price,
                                                   MaterialFeeAfter = repairContract.ContractType == ContractType.索賠
                                                       ? material.Id == null ? default : material.Price * material.Quantity
                                                       : material.Id == null ? default : material.MaterialFeeAfter ?? 0,
                                                   SettlementProperty = workItem.Id == null ? new RepairSettlementType() : workItem.SettlementProperty,
                                                   FinishDate = workItem.Id == null ? null : workItem.FinishDate,
                                                   FreeMaintainFee = fMaintainFee == null ? default : fMaintainFee.TotalFee,                                                 
                                                   RepairFee = repair.Id == null ? default : repair.TotalFeeAfter
                                               };
                return workItemAndMaterialQuery;
            }        

  我們先來看看GetAllByConvertSql這個方法實現的源碼,我們先來看這個方法的接口定義

using System;
using System.Linq;
using Abp.Domain.Entities;
using Abp.Domain.Repositories;
using Microsoft.EntityFrameworkCore;

namespace Sunlight.EFCore.Repositories {
    /// <inheritdoc />
    public interface ISdtRepository<TEntity, TPrimaryKey> : IRepository<TEntity, TPrimaryKey> where TEntity : class, IEntity<TPrimaryKey> {
        /// <summary>
        /// 用Sql查詢數據,解決有些時候無法轉換LinQ的問題
        /// </summary>
        /// <param name="sql"></param>
        /// <param name="parameters"></param>
        /// <returns></returns>
        IQueryable<TEntity> GetAllFromSqlRaw(string sql, params object[] parameters);

        /// <summary>
        /// 用Sql查詢數據,解決有些時候無法轉換LinQ的問題
        /// </summary>
        /// <param name="sql"></param>
        /// <returns></returns>
        IQueryable<TEntity> GetAllFromSqlInterpolated(FormattableString sql);

        /// <summary>
        /// 提前將表達式轉換成Sql,然后返回<see cref="IQueryable{T}"/> 類型的數據,避免ef core轉換失敗
        /// <para/>
        /// 使用時需要在 DbContext 里面創建 <typeparamref name="T"/>類型的集合:
        /// <para/>
        /// Ef Core 2.x <see cref="DbQuery{T}"/>
        /// <para/>
        /// Ef Core 3.x <see cref="DbSet{T}"/>,同時在 <see cref="DbContext.OnModelCreating(ModelBuilder)"/> 里 設置 <c>modelBuilder.Entity<T>(e => e.HasNoKey().ToView("xxx"))</c>
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="query"></param>
        /// <returns></returns>
        IQueryable<T> GetAllByConvertSql<T>(IQueryable<T> query) where T : class ;

    }
}

  這個接口中最后一個方法就是我們今天要說的GetAllByConvertSql方法,我們來看看這個具體的實現。

using System;
using System.Linq;
using System.Net.Http.Headers;
using Abp.Domain.Entities;
using Abp.EntityFrameworkCore;
using Abp.EntityFrameworkCore.Repositories;
using Microsoft.EntityFrameworkCore;
using Sunlight.EFCore.Extensions;

namespace Sunlight.EFCore.Repositories {
    /// <summary>
    /// 自定義 Repository 的基類,使用方式參照 https://aspnetboilerplate.com/Pages/Documents/Entity-Framework-Core#custom-repositories
    /// </summary>
    /// <typeparam name="TEntity">Entity type</typeparam>
    /// <typeparam name="TPrimaryKey">Primary key type of the entity</typeparam>
    /// <typeparam name="TDbContext"></typeparam>
    public abstract class SdtEfRepositoryBase<TDbContext, TEntity, TPrimaryKey> : EfCoreRepositoryBase<TDbContext, TEntity, TPrimaryKey>, ISdtRepository<TEntity, TPrimaryKey>
        where TEntity : class, IEntity<TPrimaryKey> where TDbContext : DbContext {

        /// <inheritdoc />
        protected SdtEfRepositoryBase(IDbContextProvider<TDbContext> dbContextProvider)
            : base(dbContextProvider) {
        }

        // Add your common methods for all repositories

        /// <inheritdoc />
        public IQueryable<TEntity> GetAllFromSqlRaw(string sql, params object[] parameters) {
#if NETCOREAPP2_2
            return Table.FromSql(sql, parameters);
# else
            return Table.FromSqlRaw(sql, parameters);
#endif
        }

        /// <inheritdoc />
        public IQueryable<TEntity> GetAllFromSqlInterpolated(FormattableString sql) {
#if NETCOREAPP2_2
            return Table.FromSql(sql);
# else
            return Table.FromSqlInterpolated(sql);
#endif
        }

        /// <inheritdoc />
        public IQueryable<T> GetAllByConvertSql<T>(IQueryable<T> query) where T : class {
#if NETCOREAPP2_2
            return Context.Query<T>().FromSql(query.ToSql(Context));
# else
            return Context.Set<T>().FromSqlRaw(query.ToSql());
#endif
        }
    }
}

  這個里面我們是通過繼承ABP框架中的EfCoreRepositoryBase來實現的,這個方法里面的FromSqlRaw是EFCore框架中Microsoft.EntityFrameworkCore命名空間下面的RelationalQueryableExtensions進行定義的,另外這個方法的參數是query.ToSql方法,這個按照我們的解釋是用於將IQueryable<T>轉換成sql語句的方法,這個也是我們的代碼中用到的一個擴展方法,因為很多時候我們是需要將我們的IQueryable<T>對象轉變為sql的,特別是我們需要進行報表處理的時候,有時候我們不想全部用SQL語句進行寫,有些我們要先用Linq寫然后將其轉換成SQL語句然后再和我們的sql語句進行拼接,從而達到最終的目的。

  下面我們來看看ToSql語句的具體實現。

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Data.Common;
using System.Linq;
using System.Reflection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Query;
using Sunlight.Domain.Models;
using Sunlight.EFCore.Repositories;
#if NETCOREAPP2_2
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Query.Internal;
using Microsoft.EntityFrameworkCore.Storage;
#else
using Microsoft.EntityFrameworkCore.Query.SqlExpressions;
#endif

namespace Sunlight.EFCore.Extensions {
    /// <summary>
    /// IQueryable類型的擴展方法
    /// </summary>
    public static class QueryableExtensions {
#if NETCOREAPP2_2
        /// <summary>
        /// 將查詢語句轉換成Sql, 便於進一步的Sql拼接
        /// <seealso href="https://github.com/yangzhongke/ZackData.Net/blob/master/Tests.NetCore/IQueryableExtensions.cs" />
        /// </summary>
        /// <param name="query"></param>
        /// <param name="dbCtx"></param>
        /// <typeparam name="TEntity"></typeparam>
        /// <returns></returns>
        public static string ToSql<TEntity>(this IQueryable<TEntity> query, DbContext dbCtx) {
            var modelGenerator = dbCtx.GetService<IQueryModelGenerator>();
            var queryModel = modelGenerator.ParseQuery(query.Expression);
            var databaseDependencies = dbCtx.GetService<DatabaseDependencies>();
            var queryCompilationContext = databaseDependencies.QueryCompilationContextFactory.Create(false);
            var modelVisitor = (RelationalQueryModelVisitor)queryCompilationContext.CreateQueryModelVisitor();
            modelVisitor.CreateQueryExecutor<TEntity>(queryModel);
            var sql = modelVisitor.Queries.First().ToString();
            return sql;
        }
#else

        /// <summary>
        /// 將查詢語句轉換成Sql, 便於進一步的Sql拼接
        /// <seealso href="https://gist.github.com/rionmonster/2c59f449e67edf8cd6164e9fe66c545a#gistcomment-3109335" />
        /// </summary>
        /// <param name="query"></param>
        /// <param name="dbCtx">數據庫上下文</param>
        /// <typeparam name="TEntity"></typeparam>
        /// <returns></returns>
        public static string ToSql<TEntity>(this IQueryable<TEntity> query, DbContext dbCtx = null) where TEntity : class {
            return ToSql(query);
        }

        /// <summary>
        /// 將查詢語句轉換成Sql, 便於進一步的Sql拼接
        /// <seealso href="https://gist.github.com/rionmonster/2c59f449e67edf8cd6164e9fe66c545a#gistcomment-3109335" />
        /// </summary>
        /// <param name="query"></param>
        /// <typeparam name="TEntity"></typeparam>
        /// <returns></returns>
        private static string ToSql<TEntity>(this IQueryable<TEntity> query) where TEntity : class {
            using var enumerator = query.Provider.Execute<IEnumerable<TEntity>>(query.Expression).GetEnumerator();
            var relationalCommandCache = enumerator.Private("_relationalCommandCache");
            var selectExpression = relationalCommandCache.Private<SelectExpression>("_selectExpression");
            var factory = relationalCommandCache.Private<IQuerySqlGeneratorFactory>("_querySqlGeneratorFactory");

            var sqlGenerator = factory.Create();
            var command = sqlGenerator.GetCommand(selectExpression);

            var sql = command.CommandText;
            return sql;
        }

        private static object Private(this object obj, string privateField) => obj?.GetType().GetField(privateField, BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(obj);
        private static T Private<T>(this object obj, string privateField) => (T)obj?.GetType().GetField(privateField, BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(obj);

        /// <summary>
        /// 增加
        /// </summary>
        /// <param name="query"></param>
        /// <typeparam name="TEntity"></typeparam>
        /// <returns></returns>
        public static (string, IReadOnlyDictionary<string, object>) ToSqlWithParams<TEntity>(this IQueryable<TEntity> query) {
            using var enumerator = query.Provider.Execute<IEnumerable<TEntity>>(query.Expression).GetEnumerator();
            var relationalCommandCache = enumerator.Private("_relationalCommandCache");
            var selectExpression = relationalCommandCache.Private<SelectExpression>("_selectExpression");
            var factory = relationalCommandCache.Private<IQuerySqlGeneratorFactory>("_querySqlGeneratorFactory");
            var queryContext = enumerator.Private<RelationalQueryContext>("_relationalQueryContext");

            var sqlGenerator = factory.Create();
            var command = sqlGenerator.GetCommand(selectExpression);

            var parametersDict = queryContext.ParameterValues;
            var sql = command.CommandText;
            return (sql, parametersDict);
        }
#endif
    }
}

  這個里面ToSql語句分為EFCore2.2版本和EFCore3.1版本,在這段代碼中我們使用了NETCOREAPP2_2編譯開關來區分,在使用的時候需要注意。另外我們這個是定義在ISdtRepository<TEntity, TPrimaryKey>接口中的,所以要想使用這個方法我們的Repository必須實現這個接口,在我們上面的示例代碼中,我們的_repairContractRepository的定義是這樣的

 private readonly ISdtRepository<RepairContract, Guid> _repairContractRepository;

  在EFCore中只有默認的IRepository<TEntity, TPrimaryKey>接口才會注入到依賴注入容器中去,所以我們定義的ISdtRepository<RepairContract, Guid>是無法注入到依賴注意容器中去的,那么在使用ABP框架進行開發時是怎么注入到依賴注入容器中去的呢?如果你想了解ABP中的依賴注入原理請點擊這里,回到主題,這個問題的答案是在我們定義的EFCoreModule中重寫AbpModule中的虛方法Initialize(),並通過下面這句代碼實現

IocManager.Register<ISdtRepository<RepairContract, Guid>,
                StatsRepositoryBase<RepairContract>>(DependencyLifeStyle.Transient);  

  整個過程就像下面的例子:

using System;
using System.Collections.Generic;
using System.Reflection;
using Abp.Dependency;
using Abp.EntityFrameworkCore;
using Abp.Localization.Dictionaries;
using Abp.Localization.Dictionaries.Xml;
using Abp.Modules;
using Abp.Reflection.Extensions;
using Microsoft.Extensions.Configuration;
using Sunlight.EFCore;
using Sunlight.EFCore.Repositories;
using Sunlight.Stats.Data.EntityFrameworkCore.Repository;
using Sunlight.Stats.Domain;
using Sunlight.Stats.Domain.Models.DmsAfterSales;

namespace Sunlight.Stats.Data.EntityFrameworkCore {
    [DependsOn(
        typeof(StatsDomainModule),
        typeof(AbpEntityFrameworkCoreModule))]
    public class StatsEFCoreModule : AbpModule {
        public const string LocalizationSourceName = "Sunlight";

        public override void PreInitialize() {
            // Oracle 11 目前不支持默認的 ReadUncommitted
            Configuration.UnitOfWork.IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted;

            var thisAssembly = typeof(StatsEFCoreModule).GetAssembly();
            Configuration.Localization.Sources.Add(
                new DictionaryBasedLocalizationSource(LocalizationSourceName,
                    new XmlEmbeddedFileLocalizationDictionaryProvider(thisAssembly, "Sunlight.Localization.SourceFiles")
                )
            );
        }

        public override void Initialize() {
            IocManager.RegisterAssemblyByConvention(typeof(StatsEFCoreModule).GetAssembly());
            var configuration = IocManager.Resolve<IConfiguration>();
            var dbType = Enum.Parse<DataBaseType>(configuration["DataBase:Dialect"]);
            switch (dbType) {
                case DataBaseType.SqlServer:
                case DataBaseType.Oracle:
                case DataBaseType.MySql:
                    IocManager.AddCodeGenerator<StatsDbContext, Guid>(dbType);
                    break;
                case DataBaseType.InMemory:
                    break;
                case DataBaseType.Unknown:
                default:
                    throw new ArgumentOutOfRangeException();
            }

            IocManager.Register<ISdtRepository<RepairContract, Guid>,
                StatsRepositoryBase<RepairContract>>(DependencyLifeStyle.Transient);

            DapperExtensions.DapperExtensions.SetMappingAssemblies(new List<Assembly> { typeof(StatsEFCoreModule).GetAssembly() });
        }
    }
}

  上面介紹完了整個GetAllByConvertSql方法的源碼及定義,后面我們再介紹一個重要的部分,就是我們使用這個方法的時候返回freeMaintainFees的時候,這部分的數據必須是一個視圖,只有這樣我們才能夠正確返回從而不報錯,這里我們看看在EFCore3.1中我們怎么定義這個視圖。

  1 定義實體Model

  這個定義比較簡單,代碼如下:

public class FreeMaintainFeeModel {
        public Guid RepairContractId { get; set; }

        public decimal TotalFee { get; set; }
    }

  2 在DbContext中定義成視圖類型

  A 由於EFCore3.1中取消了DbQuery類型,所以只能定義成DbSet類型,定義如下:

public DbSet<FreeMaintainFeeModel> FreeMaintainFeeModels { get; set; }

  B OnModelCreating中定義視圖

modelBuilder.Entity<FreeMaintainFeeModel>(e => e.HasNoKey().ToView("FreeMaintainFeeModel"));

  上面的介紹完成了整個過程的分析,這個信息量還是很大的需要自己去認真分析,其次需要對ABP框架和EFCore有很多的了解。

總結

  最后面我們來猜測一下為什么加了GetAllByConvertSql這個方法后的IQueryable<T>對象后然后和后面的部分一起查詢的時候整個過程就能夠正確生成SQL呢?這個是因為GetAllByConvertSql這個方法內部通過ToSql語句提前將這部分轉化成了SQL語句,后面再使用Linq和這部分的IQueryable<T>進行操作然后轉換的時候,最終查詢的SQL就會使用之前已經經過GetAllByConvertSql方法轉換好的部分SQL語句,而不是整個再將整個IQueryable重新生成一遍,如果我們將最終的IQueryable一次性生成的話,如果里面的邏輯太復雜EFCore有可能最終生成的語句達不到預期,這個時候就需要我們將里面復雜的子查詢單獨拆出來提前將其轉換成SQL,然后將這部分SQL和最終結果生成的SQL進行拼接這樣就能大大提高最終生成SQL的准確率,這個結論也是我們在經過大量總結后得出的實戰經驗,這個是非常重要的。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM