让我们为休假请求系统设计一个域。以下是我们关心的几个关键事项:
public class Employee
{
public Guid Id { get; private set; }
//Each employee has 25 vacation days in a given year.
public int VacationAllowance { get; private set; } = 25;
private readonly List<VacationRequest> vacationRequests = new();
//Vacation budget is a difference between Vacation Allowance and total days of pending and approved vacation requests
public double VacationBudget => VacationAllowance - VacationRequests
.Where(request => request.StartTime.Year == DateTime.UtcNow.Year)
.Where(request => request.State == VacationRequestState.Approved || request.State == VacationRequestState.Pending)
.Sum(request => request.TotalDays);
public IReadOnlyList<VacationRequest> VacationRequests => vacationRequests;
public VacationRequest RequestVacation(DateTime startTime, DateTime endTime)
{
var vacationRequest = new VacationRequest(startTime, endTime);
if (vacationRequest.TotalDays > VacationBudget)
{
throw new InvalidOperationException("Budget is exceeded!");
}
vacationRequests.Add(vacationRequest);
return vacationRequest;
}
}
public class VacationRequest
{
public VacationRequest(Guid id, DateTime startTime, DateTime endTime, double totalDays, VacationRequestState state)
{
Id = id;
StartTime = startTime;
EndTime = endTime;
TotalDays = totalDays;
State = state;
}
public VacationRequest(DateTime startTime, DateTime endTime)
{
Id = Guid.NewGuid();
StartTime = startTime;
EndTime = endTime;
TotalDays = (StartTime - EndTime).TotalDays;
State = VacationRequestState.Pending;
}
public Guid Id { get; private set; }
public DateTime StartTime { get; private set; }
public DateTime EndTime { get; private set; }
public double TotalDays { get; private set; }
public VacationRequestState State { get; private set; }
}
public enum VacationRequestState
{
Pending = 0,
Approved = 1,
Cancelled = 2
}
我们暂时将跳过有关如何批准或取消休假申请的详细信息。我们最关心的是计算属性。VacationBudget
尽管为预算提供计算属性既方便又方便,但 EntityFramework 将不知道如何处理它。使用 VacationBudget 进行的所有预测都将导致整个 Employee 实例实现。让我们看一下 EF 将为我们的案例生成的 SQL。
下面是我们的 DbContext 类。我们将跳过迁移,并在每次运行应用程序时重新创建一个数据库。
public class VacationSystemContext : DbContext
{
public VacationSystemContext(DbContextOptions options) : base(options)
{
}
public DbSet<Employee> Employees { get; }
}
让我们运行一个简单的查询来获取员工 ID 和相应 VacationBudgets 的列表:
var query = dbContext.Employees.Select(employee => new
{
employee.Id,
employee.VacationBudget
}).ToQueryString();
这就是 EF 将为我们生成的内容:
SELECT "e"."Id"
FROM "Employees" AS "e"
因此,我们可以看到 EF 不知道如何计算 VacationBudget 属性,因此它将具体化所有员工并对具体化实体执行 Select。无论 EF 如何努力,给定的查询最终都会失败,因为 VacationRequests 表未联接。
在本文中,我想创建一种方法来“教导”实体框架使用计算属性并将其转换为 SQL。
首先,我们可以参考最新的 EntityFramework 功能之一:IQueryExpressionInterceptor。
实现此接口的类将有权访问即将编译为数据库查询的 ExpressionTree。实现此拦截器将允许我们找到对 Computed 属性的调用,并将它们替换为相应的表达式树。
最近发布的另一件事是 SourceGenerators。它们允许发出 C# 代码来扩展现有代码库。它们将帮助我们分析计算属性,并帮助我们将其表示为表达式树。
以下是我们将如何实现我们的目标:
一开始,我们必须创建一个属性来标记我们想要转换的所有计算属性:
private const string EfFriendlyAttributeSource = @"
namespace Lex45x.EntityFramework.ComputedProperties;
/// <summary>
/// Marks a property as one that has to be translated to respective ExpressionTree and substituted in EF queries
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public class EfFriendlyAttribute : Attribute
{
}";
此属性被定义为常量字符串,因为我们的生成器稍后会将其添加为引用此包的项目源代码的一部分。
以下是生成器的样子:
[Generator]
public class ComputedPropertiesGenerator : ISourceGenerator
{
private const string EfFriendlyAttributeSource = @"
namespace Lex45x.EntityFramework.ComputedProperties;
/// <summary>
/// Marks a property as one that has to be translated to respective ExpressionTree and substituted in EF queries
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public class EfFriendlyAttribute : Attribute
{
}";
public void Initialize(GeneratorInitializationContext context)
{
}
public void Execute(GeneratorExecutionContext context)
{
context.AddSource("EfFriendlyAttribute.g.cs", SourceText.From(EfFriendlyAttributeSource, Encoding.UTF8));
}
}
现在,我们的 ComputedPropertiesGenerator 会将我们的 Attribute 类添加为 EfFriendlyAttribute.g.cs 文件,并将其包含在引用项目的编译过程中。
出于我们的目的,我们需要一个字典,它将相应的 PropertyInfo 映射到实现所需行为的 Expression。它可能是什么样子的:
public static class EfFriendlyPropertiesLookup
{
public static IReadOnlyDictionary<PropertyInfo, LambdaExpression> ComputedPropertiesExpression { get; } = new Dictionary<PropertyInfo, LambdaExpression>
{
[typeof(Employee).GetProperty("VacationBudget")] = (Expression<Func<Employee, double>>)((entity) => entity.VacationAllowance - entity.VacationRequests
.Where(request => request.StartTime.Year == DateTime.UtcNow.Year)
.Where(request => request.State == VacationRequestState.Approved || request.State == VacationRequestState.Pending)
.Sum(request => request.TotalDays))
};
}
这里需要注意几点:
现在,让我们试着弄清楚,我们如何生成一个类似于这个类的类。
下一步是查找具有 EfFriendly 属性的所有计算属性。为了实现它,我们必须注册我们的 ISyntaxContextReceiver 接口实现。
在我们的例子中,我们必须过滤用属性标记的属性,并捕获属性实现以及属性符号(类属性的语义表示)。下面是我们的语法接收器的样子:
public class EntitySyntaxReceiver : ISyntaxContextReceiver
{
public List<(IPropertySymbol Symbol, PropertyDeclarationSyntax Syntax)> Properties { get; } = new();
public void OnVisitSyntaxNode(GeneratorSyntaxContext context)
{
// we care only about property declarations
if (context.Node is not PropertyDeclarationSyntax { AttributeLists.Count: > 0 } propertyDeclarationSyntax)
{
return;
}
//we have to get a symbol to access information about attributes
var declaredSymbol = (IPropertySymbol)context.SemanticModel.GetDeclaredSymbol(context.Node)!;
var attributes = declaredSymbol.GetAttributes();
if (!attributes.Any(data => data.AttributeClass?.ToDisplayString() == "EfFriendly"))
{
return;
}
Properties.Add((declaredSymbol, propertyDeclarationSyntax));
}
}
然后,我们必须在 Generator 的 Initialize 方法中注册它,并在 Execute 方法中使用它:
[Generator]
public class ComputedPropertiesGenerator : ISourceGenerator
{
private const string EfFriendlyAttributeSource = @"
namespace Lex45x.EntityFramework.ComputedProperties;
/// <summary>
/// Marks a property as one that has to be translated to respective ExpressionTree and substituted in EF queries
/// \</summary>
[AttributeUsage(AttributeTargets.Property)]
public class EfFriendlyAttribute : Attribute
{
}";
public void Initialize(GeneratorInitializationContext context)
{
//register our receiver
context.RegisterForSyntaxNotifications(() => new EntitySyntaxReceiver());
}
public void Execute(GeneratorExecutionContext context)
{
//proceed only with EntitySyntaxReceiver in the context
if (context.SyntaxContextReceiver is not EntitySyntaxReceiver receiver)
return;
context.AddSource("EfFriendlyAttribute.g.cs", SourceText.From(EfFriendlyAttributeSource, Encoding.UTF8));
}
让我们看看我们可以用这种方法捕获什么样的信息。
以下是我们可以访问的 VacationBudget Get-Method 正文:
"=> VacationAllowance - VacationRequests
.Where(request => request.StartTime.Year == DateTime.UtcNow.Year)
.Where(request => request.State == VacationRequestState.Approved || request.State == VacationRequestState.Pending)
.Sum(request => request.TotalDays)"
现在,参考我们想要创建的类,我们可以看到不可能按原样使用属性体。因为我们必须用 lambda 参数的用法替换 implicit 的所有用法。EfFriendlyPropertiesLookupthis
幸运的是,我们可以为此使用它。它允许我们分析语法节点的组件。因此,知道包含该属性的类型后,我们可以突出显示 Get-Method 正文中使用的所有属性:CSharpSyntaxWalker
public class ComputedPropertySymbolVisitor : CSharpSyntaxWalker
{
private readonly INamedTypeSymbol currentType;
public IReadOnlyList<string> UsedProperties => usedProperties;
private readonly List<string> usedProperties = new();
public ComputedPropertySymbolVisitor(INamedTypeSymbol currentType)
{
this.currentType = currentType;
}
public override void VisitIdentifierName(IdentifierNameSyntax node)
{
var referencedProperty = currentType.GetMembers(node.Identifier.ValueText);
if (referencedProperty.Length > 0)
{
usedProperties.Add(node.Identifier.ValueText);
}
base.VisitIdentifierName(node);
}
}
由于我们已经需要大量的属性信息,所以让我们创建一个类来保存这些信息:
public class ComputedPropertyDeclaration
{
public IPropertySymbol Symbol { get; }
public PropertyDeclarationSyntax UnderlyingSyntax { get; }
public IReadOnlyList<string> ReferencedProperties { get; }
public ComputedPropertyDeclaration(IPropertySymbol symbol, PropertyDeclarationSyntax underlyingSyntax,
IReadOnlyList<string> referencedProperties)
{
Symbol = symbol;
UnderlyingSyntax = underlyingSyntax;
ReferencedProperties = referencedProperties;
}
}
下面是 EntitySyntaxReceiver 的最终版本:
public class EntitySyntaxReceiver : ISyntaxContextReceiver
{
public List<ComputedPropertyDeclaration> Properties { get; } = new();
public void OnVisitSyntaxNode(GeneratorSyntaxContext context)
{
if (context.Node is not PropertyDeclarationSyntax { AttributeLists.Count: > 0 } propertyDeclarationSyntax)
{
return;
}
var declaredSymbol = (IPropertySymbol)context.SemanticModel.GetDeclaredSymbol(context.Node)!;
var attributes = declaredSymbol.GetAttributes();
if (!attributes.Any(data => data.AttributeClass?.ToDisplayString() == "EfFriendly"))
{
return;
}
var visitor = new ComputedPropertySymbolVisitor(declaredSymbol.ContainingType);
visitor.Visit(propertyDeclarationSyntax.ExpressionBody);
Properties.Add(new ComputedPropertyDeclaration(declaredSymbol, propertyDeclarationSyntax,
visitor.UsedProperties));
}
}
现在,我们已经拥有了生成映射类所需的一切。
让我们关注 Generator 的 Execute 方法。我们必须为一个静态类添加源代码,该类将使用我们找到的所有计算属性预先初始化字典。这是它的样子:
public void Execute(GeneratorExecutionContext context)
{
if (context.SyntaxContextReceiver is not EntitySyntaxReceiver receiver)
return;
context.AddSource("EfFriendlyAttribute.g.cs", SourceText.From(EfFriendlyAttributeSource, Encoding.UTF8));
var namespacesBuilder = new HashSet<string>();
//making sure that all symbol namespaces will be imported
foreach (var property in receiver.Properties)
{
namespacesBuilder.Add(property.Symbol.ContainingNamespace.ToString());
}
var computedPropertiesLookup = @$"
using System.Linq.Expressions;
using System.Reflection;
{namespacesBuilder.Aggregate(new StringBuilder(), (builder, s) => builder.AppendLine($"using {s};"))}
public static class EfFriendlyPropertiesLookup
{{
public static IReadOnlyDictionary<PropertyInfo, Expression> ComputedPropertiesExpression {{ get; }} = new Dictionary\<PropertyInfo, Expression>
{{
{receiver.Properties.Aggregate(new StringBuilder(), (builder, declaration) => builder.AppendLine($"[{declaration.GetPropertyInfoDeclaration()}] = {declaration.GetExpressionDeclaration()},"))}
}};
}}";
context.AddSource("EfFriendlyPropertiesLookup.g.cs", SourceText.From(computedPropertiesLookup, Encoding.UTF8));
}
为了清理我们的模板,我已将字典键和值生成委托给类,因为它已经拥有所有必要的数据。这是它的更新版本:ComputedPropertyDeclaration
public class ComputedPropertyDeclaration
{
public IPropertySymbol Symbol { get; }
public PropertyDeclarationSyntax UnderlyingSyntax { get; }
public IReadOnlyList<string> ReferencedProperties { get; }
public ComputedPropertyDeclaration(IPropertySymbol symbol, PropertyDeclarationSyntax underlyingSyntax,
IReadOnlyList<string> referencedProperties)
{
Symbol = symbol;
UnderlyingSyntax = underlyingSyntax;
ReferencedProperties = referencedProperties;
}
public string GetExpressionDeclaration()
{
var getMethodBody = UnderlyingSyntax.ExpressionBody!.ToFullString();
foreach (var usedProperty in ReferencedProperties)
{
getMethodBody = getMethodBody.Replace(usedProperty, $"entity.{usedProperty}");
}
return $"(Expression<Func<{Symbol.ContainingType.Name},{Symbol.Type.Name}>>) ((entity) {getMethodBody})";
}
public string GetPropertyInfoDeclaration()
{
return $"typeof({Symbol.ContainingType}).GetProperty(\\"{Symbol.Name}\\")";
}
}
最后,我们将得到一个在编译过程中构建的静态类 EfFriendlyPropertiesLookup,其中包含所有标记的属性及其表达式表示形式。
现在,我们已准备好继续 EntityFramework 部分。
我们必须为拦截器创建另一个项目,因为源生成器应该以 netstandard2.0 为目标。
在这个项目中,让我们创建接口的实现:IQueryExpressionInterceptor
public class ComputedPropertyCallInterceptor : IQueryExpressionInterceptor
{
public Expression QueryCompilationStarting(Expression queryExpression, QueryExpressionEventData eventData)
{
throw new NotImplementedException();
}
}
QueryCompilationStarting该方法将在表达式树编译为数据库查询之前调用。让我们在 DbContext 中注册我们的拦截器,看看我们在那里得到什么样的数据:
public class VacationSystemContext : DbContext
{
public VacationSystemContext(DbContextOptions options) : base(options)
{
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.AddInterceptors(new ComputedPropertyCallInterceptor());
base.OnConfiguring(optionsBuilder);
}
public DbSet<Employee> Employees { get; private set; }
}
随着上下文的更新,让我们重新运行测试。提醒一下,以下是我们在测试中执行的查询:
var query = dbContext.Employees.Select(employee => new
{
employee.Id,
employee.VacationBudget
}).ToQueryString();
这是我们在 queryExpression 参数中的内容:
[Microsoft.EntityFrameworkCore.Query.EntityQueryRootExpression]
.Select(employee => new <>f__AnonymousType0`2(Id = employee.Id, VacationBudget = employee.VacationBudget))
现在,我们可以看到我们正在调用 IQueryable.Select 方法并将 lambda 作为参数传递。在这个 lambda 中,我们可以看到对员工的调用。假期预算。
我们的拦截器的目标是更换员工。VacationBudget 设置为生成的 EfFriendlyPropertiesLookup 类中的表达式。
entity => (Convert(entity.VacationAllowance, Double)
- entity.VacationRequests
.Where(request => (request.StartTime.Year == DateTime.UtcNow.Year))
.Where(request => ((Convert(request.State, Int32) == 1) OrElse (Convert(request.State, Int32) == 0))).Sum(request => request.TotalDays))
但是,我们不能只是从字典中获取一个 lambda 表达式并使用它,我们必须获取它的主体并用 queryExpression 中的适当参数引用替换其中的参数。否则,我们的表达式树将无效。entity
幸运的是,dotnet 有一个特殊的类可以为我们解决这两个问题:ExpressionVisiter,它使我们能够遍历表达式树并对其进行更改。让我们创建一个第一个访问者,它将找到对计算属性的所有调用:
internal class ComputedPropertiesVisitor : ExpressionVisitor
{
private readonly IReadOnlyDictionary<PropertyInfo, LambdaExpression> configuration;
public ComputedPropertiesVisitor(IReadOnlyDictionary<PropertyInfo, LambdaExpression> configuration)
{
this.configuration = configuration;
}
protected override Expression VisitMember(MemberExpression node)
{
if (node.Member is not PropertyInfo propertyInfo || !configuration.ContainsKey(propertyInfo))
{
return base.VisitMember(node);
}
var expression = configuration[propertyInfo];
var resultExpression = expression.Body;
var parametersToReplace = expression.Parameters;
var parameterExpression = parametersToReplace[0];
var expressionVisitor = new ReplaceParametersExpressionVisitor(parameterExpression, node.Expression);
resultExpression = expressionVisitor.Visit(resultExpression);
return resultExpression;
}
}
ReplaceParametersExpressionVisitor 是我们的第二个访问者,它将获取表达式正文并替换其中的参数:
internal class ReplaceParametersExpressionVisitor : ExpressionVisitor
{
private readonly ParameterExpression propertyParameter;
private readonly Expression expression;
public ReplaceParametersExpressionVisitor(ParameterExpression propertyParameter, Expression expression)
{
this.propertyParameter = propertyParameter;
this.expression = expression;
}
protected override Expression VisitParameter(ParameterExpression node)
{
return node == propertyParameter ? expression : base.VisitParameter(node);
}
}
现在,让我们在拦截器中使用这些访问者,并定义一个构造函数参数来获取我们的映射字典:
public class ComputedPropertyCallInterceptor : IQueryExpressionInterceptor
{
private readonly ComputedPropertiesVisitor visitor;
public ComputedPropertyCallInterceptor(IReadOnlyDictionary\<PropertyInfo, LambdaExpression> configuration)
{
visitor = new ComputedPropertiesVisitor(configuration);
}
public Expression QueryCompilationStarting(Expression queryExpression, QueryExpressionEventData eventData)
{
return visitor.Visit(queryExpression);
}
}
因此,我们必须更新拦截器注册并使用 EfFriendlyPropertiesLookup 类:
public class VacationSystemContext : DbContext
{
public VacationSystemContext(DbContextOptions options) : base(options)
{
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.AddInterceptors(
new ComputedPropertyCallInterceptor(EfFriendlyPropertiesLookup.ComputedPropertiesExpression));
base.OnConfiguring(optionsBuilder);
}
public DbSet<Employee> Employees { get; private set; }
}
就是这样!让我们运行测试并比较生成的查询。
以下是测试机构的提醒:
public class SqlGenerationTests
{
private VacationSystemContext dbContext = null!;
[SetUp]
public async Task Setup()
{
var dbContextOptionsBuilder = new DbContextOptionsBuilder<VacationSystemContext>().UseSqlite("DataSource=InMemory;Mode=Memory;Cache=Shared");
dbContext = new VacationSystemContext(dbContextOptionsBuilder.Options);
await dbContext.Database.OpenConnectionAsync();
await dbContext.Database.EnsureDeletedAsync();
var creationResult = await dbContext.Database.EnsureCreatedAsync();
}
[Test]
public async Task VacationBudgetProjection()
{
var query = dbContext.Employees.Select(employee => new
{
employee.Id,
employee.VacationBudget
}).ToQueryString();
}
}
以下是 VacationBudget 的定义:
//Vacation budget is a difference between Vacation Allowance and total days of pending and approved vacation requests
[EfFriendly]
public double VacationBudget => VacationAllowance - VacationRequests
.Where(request => request.StartTime.Year == DateTime.UtcNow.Year)
.Where(request => request.State == VacationRequestState.Approved || request.State == VacationRequestState.Pending)
.Sum(request => request.TotalDays);
这是我们在没有拦截器的情况下遇到的查询:
SELECT "e"."Id"
FROM "Employees" AS "e"
现在,在我们完成所有工作之后,以下是我们最终将要进行的查询:
SELECT "e"."Id", CAST("e"."VacationAllowance" AS REAL) - (
SELECT COALESCE(SUM("v"."TotalDays"), 0.0)
FROM "VacationRequest" AS "v"
WHERE "e"."Id" = "v"."EmployeeId" AND CAST(strftime('%Y', "v"."StartTime") AS INTEGER) = CAST(strftime('%Y', 'now') AS INTEGER) AND "v"."State" IN (1, 0)) AS "VacationBudget"
FROM "Employees" AS "e"
它完全按照我们在计算属性正文中定义的方式执行!