C-9-和--NET5-高级教程-十三-

75 阅读59分钟

C#9 和 .NET5 高级教程(十三)

原文:Pro C# 9 with .NET 5

协议:CC BY-NC-SA 4.0

二十三、实用实体框架核心构建数据访问层

上一章详细介绍了 EF 核心及其功能。本章着重于应用您所学的 EF 核心知识来构建AutoLot数据访问层。您可以通过搭建实体并从前一章的数据库中导出DbContext来开始这一章。然后,项目从数据库优先改为代码优先,实体被更新到它们的最终版本,并使用 EF 核心迁移应用到数据库。对数据库的最后一个更改是重新创建GetPetName存储过程,并创建一个新的数据库视图(包括一个匹配的视图模型),所有这些都使用迁移。

下一步是创建存储库,提供对数据库的独立创建、读取、更新和删除(CRUD)访问。然后,将数据初始化代码(包括示例数据)添加到项目中,以便在测试中使用。本章的剩余部分将通过自动化集成测试来测试驱动AutoLot数据访问层。

代码优先还是数据库优先

在我们开始构建数据访问层之前,让我们花点时间来讨论使用 EF Core 和您的数据库的两种不同方式:代码优先和数据库优先。这两种方法都是使用 EF Core 的有效方法,至于使用哪种方法,很大程度上取决于您的开发团队。

代码优先意味着您在代码中创建和配置您的实体类和派生的DbContext,然后使用迁移来更新数据库。这就是大多数新建项目的开发方式。这样做的好处是,当您构建应用时,您的实体会根据应用的需求而发展。迁移使数据库保持同步,因此数据库设计会随着应用的发展而发展。这种新兴的设计过程很受敏捷开发团队的欢迎,因为您在正确的时间构建了正确的部分。

如果您已经有了一个数据库,或者更喜欢让您的数据库设计来驱动您的应用,这被称为数据库优先。不是手工创建派生的DbContext和所有的实体,而是从数据库中构建类。当数据库发生变化时,您需要重新搭建您的类,以保持您的代码与数据库同步。实体或派生的DbContext中的任何定制代码必须放在分部类中,这样当类被重新搭建时就不会被覆盖。幸运的是,搭建过程正是出于这个原因创建了分部类。

无论您选择哪种方法,代码优先还是数据库优先,都要知道这是一种承诺。如果首先使用代码,则对实体和上下文类进行所有更改,并使用迁移来更新数据库。如果您首先使用数据库,则必须在数据库中进行所有更改,然后重新构建类。通过一些努力和计划,您可以从数据库优先切换到代码优先(反之亦然),但是您不应该同时对代码和数据库进行手动更改。

创建自动 Lot。达尔和奥托洛特。模型项目

AutoLot数据访问层由两个项目组成,一个包含 EF 核心特定的代码(派生的DbContext、上下文工厂、存储库、迁移等)。)另一个用来保存实体和视图模型。创建一个名为 Chapter23_AllProjects 的新解决方案,并在该解决方案中添加一个名为AutoLot.Models的. NET 核心类库。删除使用模板创建的默认类,并将以下 NuGet 包添加到项目中:

  • Microsoft.EntityFrameworkCore.Abstractions

  • System.Text.Json

T``Microsoft.EntityFrameworkCore.Abstractions包提供了对许多 EF 核心构造(如数据注释)的访问,并且比Microsoft.EntityFrameworkCore包更轻。

再加一个。NET 核心类库项目命名为AutoLot.Dal地解决方案。删除使用模板创建的默认类,添加对AutoLot.Models项目的引用,并将以下 NuGet 包添加到项目中:

  • Microsoft.EntityFrameworkCore

  • Microsoft.EntityFrameworkCore.SqlServer

  • Microsoft.EntityFrameworkCore.Design

T``Microsoft.EntityFrameworkCore包提供了 EF 内核的通用功能。Microsoft.EntityFrameworkCore.SqlServer包提供了 SQL Server 数据提供者,EF 核心命令行工具需要Microsoft.EntityFrameworkCore.Design包。

要使用命令行完成所有这些步骤,请使用以下命令(在要创建解决方案的目录中):

dotnet new sln -n Chapter23_AllProjects

dotnet new classlib -lang c# -n AutoLot.Models -o .\AutoLot.Models -f net5.0
dotnet sln .\Chapter23_AllProjects.sln add .\AutoLot.Models
dotnet add AutoLot.Models package Microsoft.EntityFrameworkCore.Abstractions
dotnet add AutoLot.Models package System.Text.Json

dotnet new classlib -lang c# -n AutoLot.Dal -o .\AutoLot.Dal -f net5.0
dotnet sln .\Chapter23_AllProjects.sln add .\AutoLot.Dal
dotnet add AutoLot.Dal reference AutoLot.Models
dotnet add AutoLot.Dal package Microsoft.EntityFrameworkCore
dotnet add AutoLot.Dal package Microsoft.EntityFrameworkCore.Design
dotnet add AutoLot.Dal package Microsoft.EntityFrameworkCore.SqlServer
dotnet add AutoLot.Dal package Microsoft.EntityFrameworkCore.Tools

Note

如果您使用的不是基于 Windows 的计算机,请根据您的操作系统调整目录分隔符。本章中的所有 CLI 命令都需要这样做。

创建项目后,更新每个*.csproj文件以启用 C# 8 可空引用类型。此处更新以粗体显示:

<PropertyGroup>
  <TargetFramework>net5.0</TargetFramework>
  <Nullable>enable</Nullable>
</PropertyGroup>

搭建 DbContext 和实体

下一步是使用 EF 核心命令行工具搭建第二十一章的AutoLot数据库。在命令提示符或 Visual Studio 的包管理器控制台中导航到AutoLot.Dal项目目录。

Note

在 repo 的第二十一章的文件夹中有 Windows 和 Docker 的数据库备份。如果需要恢复数据库,请参考第二十一章中的说明。

使用 EF Core CLI 工具,通过以下命令将AutoLot数据库移植到实体和DbContext派生类中(全部在一行中):

dotnet ef dbcontext scaffold "server=.,5433;Database=AutoLot;User Id=sa;Password=P@ssw0rd;" Microsoft.EntityFrameworkCore.SqlServer -d -c ApplicationDbContext --context-namespace AutoLot.Dal.EfStructures --context-dir EfStructures --no-onconfiguring -n AutoLot.Models.Entities -o ..\AutoLot.Models\Entities

前面的命令使用 SQL Server 数据库提供程序搭建位于所提供的连接字符串(这是第二十一章中使用的 Docker 容器的连接字符串)的数据库。-d标志是在可能的情况下优先考虑数据注释(通过 Fluent API)。-c命名上下文,--context-namespaces指定上下文的名称空间,--context-dir表示上下文的目录(相对于当前项目),--no-onconfiguring防止OnConfiguring方法被搭建,-o是实体的输出目录(相对于项目目录),-n指定实体的名称空间。该命令将所有实体放置在自动 Lot 中。模型投影在entities文件夹中,并将ApplicationDbContext放置在自动 Lot 的EfStructures文件夹中。Dal 项目。

如果您一直在学习这一章,您会注意到存储过程并没有被搭建起来。如果数据库中有任何视图,它们将被搭建成无键实体。因为没有直接映射到存储过程的 EF 核心构造,所以没有任何东西可以支撑存储过程。可以使用 EF Core 创建存储过程和其他 SQL 对象,但是目前只搭建了表和视图。

首先切换到代码

既然您已经将数据库搭建成实体,那么是时候从数据库优先切换到代码优先了。要进行切换,必须创建一个上下文工厂,并从项目的当前状态创建一个迁移。接下来,要么通过删除并重新创建数据库来应用迁移,要么通过“欺骗”EF 核心来假应用迁移。

创建 DbContext 设计时工厂

正如你在第二十二章中回忆的,EF 核心命令行工具使用IDesignTimeDbContextFactory来创建派生DbContext类的一个实例。在 AutoLot 中创建一个名为ApplicationDbContextFactory.cs的新类文件。EfStructures目录下的 Dal 项目。将下列命名空间添加到类中:

using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;

工厂的细节在前一章已经介绍过了,所以我在这里只列出代码。对Console.WriteLine()的额外调用将连接字符串输出到控制台。这只是为了提供信息。确保更新您的连接字符串以匹配您的环境。

namespace AutoLot.Dal.EfStructures
{
  public class ApplicationDbContextFactory : IDesignTimeDbContextFactory<ApplicationDbContext>
  {
    public ApplicationDbContext CreateDbContext(string[] args)
    {
      var optionsBuilder = new DbContextOptionsBuilder<ApplicationDbContext>();
      var connectionString = @"server=.,5433;Database=AutoLot;User Id=sa;Password=P@ssw0rd;";
      optionsBuilder.UseSqlServer(connectionString);
      Console.WriteLine(connectionString);
      return new ApplicationDbContext(optionsBuilder.Options);
    }
  }
}

创建初始迁移

回想一下,第一次迁移将创建三个文件:两个文件用于迁移分部类,第三个文件是完整的模型快照。在命令提示符下,在AutoLot.Dal目录中输入以下内容,创建一个名为Initial的新迁移(使用刚刚搭建好的ApplicationDbContext实例),并将迁移文件放在 AutoLot 的EfStructures\Migrations文件夹中。Dal 项目:

dotnet ef migrations add Initial -o EfStructures\Migrations -c AutoLot.Dal.EfStructures.ApplicationDbContext

Note

在创建和应用第一次迁移之前,务必确保不会对生成的文件或数据库进行任何更改。任何一方的更改都会导致代码和数据库不同步。一旦应用,对数据库的所有更改都需要通过 EF 核心迁移来完成。

要确认迁移已创建并等待应用,请执行list命令。

dotnet ef migrations list -c AutoLot.Dal.EfStructures.ApplicationDbContext

结果将显示Initial迁移挂起(您的时间戳将不同)。由于CreateDbContext()方法中的Console.Writeline(),连接字符串显示在输出中。

Build started...
Build succeeded.
server=.,5433;Database=AutoLot;User Id=sa;Password=P@ssw0rd;
20201231203939_Initial (Pending)

应用迁移

将迁移应用到数据库的最简单方法是删除数据库并重新创建它。如果这是一个选项,您可以输入以下命令并继续下一部分:

dotnet ef database drop -f
dotnet ef database update Initial -c AutoLot.Dal.EfStructures.ApplicationDbContext

如果不能删除并重新创建数据库(例如,它是一个 Azure SQL 数据库),那么 EF Core 需要相信已经应用了迁移。幸运的是,这很简单,所有的工作都由 EF Core 完成。首先,使用以下命令从迁移中创建 SQL 脚本:

dotnet ef migrations script --idempotent -o FirstMigration.sql

这个脚本的相关部分是创建__EFMigrationsHistory表,然后将迁移记录添加到表中以表明它已被应用。将这些片段复制到 Azure Data Studio 或 SQL Server Manager Studio 中的新查询中。以下是您需要的 SQL 代码(您的时间戳会有所不同):

IF OBJECT_ID(N'[__EFMigrationsHistory]') IS NULL
BEGIN
    CREATE TABLE [__EFMigrationsHistory] (
        [MigrationId] nvarchar(150) NOT NULL,
        [ProductVersion] nvarchar(32) NOT NULL,
        CONSTRAINT [PK___EFMigrationsHistory] PRIMARY KEY ([MigrationId])
    );
END;
GO

INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
VALUES (N'20201231203939_Initial', N'5.0.1');

现在,如果您运行list命令,它将不再把Initial迁移显示为挂起。随着初始迁移的应用,项目和数据库是同步的,开发将首先继续代码。

更新模型

这个部分将所有当前实体更新到它们的最终版本,并添加一个日志实体。请注意,在本节完成之前,您的项目不会编译。

实体

AutoLot.Models项目的Entities目录中,您会发现五个文件,每个文件对应数据库中的一个表。请注意,名称是单数,而不是复数(因为它们在数据库中)。这是 EF Core 5 中的一个变化,在 EF Core 5 中,当从数据库中移植实体时,默认情况下 multivarizer 是打开的。

您将对实体进行的更改包括添加一个基类,创建一个拥有的Person实体,修复导航属性名称,以及添加一些附加属性。您还将添加一个新的日志实体(将被 ASP.NET 核心章节使用)。上一章深入介绍了 EF 核心约定、数据注释和 Fluent API,因此本节的大部分内容都是代码清单和简要说明。

BaseEntity 类

BaseEntity类将保存每个实体上的IdTimeStamp列。在AutoLot.Models项目的Entities目录下创建一个名为Base的新目录。在这个目录中,创建一个名为BaseEntity.cs的新文件。更新代码以匹配以下内容:

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace AutoLot.Models.Entities.Base
{
  public abstract class BaseEntity
  {
    [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }
    [Timestamp]
    public byte[]? TimeStamp { get; set; }
  }
}

AutoLot数据库中搭建的所有实体都将被更新以使用这个基类。

所有者实体

CustomerCreditRisk实体都有FirstNameLastName属性。每个实体都具有完全相同的属性,将这些属性移动到自己的类中会有好处。虽然两个属性是一个微不足道的例子,但拥有的实体有助于减少代码重复和增加一致性。除了类中的两个属性之外,还添加了一个将映射到 SQL Server 计算列的新属性。

在 AutoLot 的Entities目录中创建一个名为Owned的新目录。模型项目。在这个新目录中,创建一个名为Person.cs的新文件。更新代码以匹配以下内容:

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;

namespace AutoLot.Models.Entities.Owned
{
  [Owned]
  public class Person
  {
    [Required, StringLength(50)]
    public string FirstName { get; set; } = "New";

    [Required, StringLength(50)]
    public string LastName { get; set; } = "Customer";

    [DatabaseGenerated(DatabaseGeneratedOption.Computed)]
    public string? FullName { get; set; }
  }
}

属性FullName可以为空,因为新实体在保存到数据库之前不会设置值。将使用 Fluent API 添加Fullname属性的最终配置。

汽车(库存)实体

Inventory表被搭建到一个名为Inventory的实体类上。我们更喜欢用Car这个名字。这很容易解决:将文件名改为Car.cs,将类名改为CarTable属性已经被正确应用,所以只需添加dbo模式。注意,schema 参数是可选的,因为 SQL Server 默认为dbo,但是为了完整起见,我将它包括在内。

[Table("Inventory", Schema = "dbo")]
[Index(nameof(MakeId), Name = "IX_Inventory_MakeId")]
public partial class Car : BaseEntity
{
...
}

更新using语句以匹配以下内容:

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using AutoLot.Models.Entities.Base;
using Microsoft.EntityFrameworkCore;

接下来,从BaseEntity继承,并移除IdTimeStamp属性、构造函数和 pragma #nullable disable。以下是经过这些更改后的类代码:

namespace AutoLot.Models.Entities
{
  [Table("Inventory", Schema = "dbo")]
  [Index(nameof(MakeId), Name = "IX_Inventory_MakeId")]
  public partial class Car : BaseEntity
  {
    public int MakeId { get; set; }
    [Required]
    [StringLength(50)]
    public string Color { get; set; }
    [Required]
    [StringLength(50)]
    public string PetName { get; set; }
    [ForeignKey(nameof(MakeId))]
    [InverseProperty("Inventories")]
    public virtual Make Make { get; set; }
    [InverseProperty(nameof(Order.Car))]
    public virtual ICollection<Order> Orders { get; set; }
  }
}

这段代码仍然有一些问题需要解决,而且还需要添加新的属性。ColorPetName属性被设置为不可空,但是值没有在构造函数中设置,也没有用属性定义初始化。这可以通过给每个属性分配一个初始化器来解决。将DisplayName属性添加到PetName属性中,以获得一个更好的、人类可读的名称。更新属性以匹配以下内容(更改以粗体显示):

[Required]
[StringLength(50)]
public string Color { get; set; } = "Gold";

[Required]
[StringLength(50)]
[DisplayName("Pet Name")]
public string PetName { get; set; } = "My Precious";

Note

属性由 ASP.NET 核心使用,将在第八部分中介绍。

Make导航属性需要重命名为MakeNavigation并使其可为空,反向属性使用一个神奇的字符串,而不是 C# nameof()方法。最后的改变是去掉virtual修改器。以下是更新后的属性:

[ForeignKey(nameof(MakeId))]
[InverseProperty(nameof(Make.Cars))]
public Make? MakeNavigation { get; set; }

Note

延迟加载需要虚拟修饰符。因为本书中没有一个例子使用延迟加载,所以虚拟修饰符将从数据访问层的所有属性中删除。

Orders导航属性需要JsonIgnore属性来防止序列化对象模型时的循环 JSON 引用。搭建的代码确实在逆向属性中使用了nameof()方法,但是需要更新,因为所有引用导航属性的名称都将添加后缀Navigation。最后的改变是将属性的类型改为IEnumerable<Order>而不是ICollection<Order>,并用新的List<Order>初始化。这不是必需的改变,因为ICollection<Order>也可以工作。我更喜欢在集合导航属性上使用较低级别的IEnumerable<T>(因为IQueryable<T>ICollection<T>都是从IEnumerable<T>派生的)。更新代码以匹配以下内容:

[JsonIgnore]
[InverseProperty(nameof(Order.CarNavigation))]
public IEnumerable<Order> Orders { get; set; } = new List<Order>();

接下来,添加一个将显示CarMake值的NotMapped属性。这消除了对章节 21 中CarViewModel的需要。如果从带有Car记录的数据库中检索到相关的Make信息,将显示Make Name。如果未检索到相关数据,该属性将显示“未知”提醒一下,NotMapped属性不是数据库的一部分,只存在于实体上。添加以下内容:

[NotMapped]
public string MakeName => MakeNavigation?.Name ?? "Unknown";

超越ToString()显示车辆信息。

public override string ToString()
{
  // Since the PetName column could be empty, supply
  // the default name of **No Name**.
  return $"{PetName ?? "**No Name**"} is a {Color} {MakeNavigation?.Name} with ID {Id}.";
}

RequiredDisplayName属性添加到MakeId中。尽管 EF 核心认为MakeId属性是必需的,因为它不可为空,但是 ASP.NET 核心验证引擎需要Required属性。更新代码以匹配以下内容:

[Required]
[DisplayName("Make")]
public int MakeId { get; set; }

最后一项更改是添加不可空的bool IsDrivable属性,该属性带有一个可空的支持字段和一个显示名称。

private bool? _isDrivable;

[DisplayName("Is Drivable")]
public bool IsDrivable
{
  get => _isDrivable ?? false;
  set => _isDrivable = value;
}

这就完成了更新的Car实体。

客户实体

Customers表被搭建到一个名为Customer的实体类上。更新using语句以匹配以下内容:

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using AutoLot.Models.Entities.Base;
using AutoLot.Models.Entities.Owned;

接下来,从BaseEntity继承并删除IdTimeStamp属性。删除构造函数和杂注#nullable disable并添加带有模式的Table属性。删除FirstNameLastName属性,因为它们将被Person拥有的实体所取代。这是目前类别代码的位置:

namespace AutoLot.Models.Entities
{
  [Table("Customers", Schema = "dbo")]
  public partial class Customer : BaseEntity
  {
    [InverseProperty(nameof(CreditRisk.Customer))]
    public virtual ICollection<CreditRisk> CreditRisks { get; set; }
    [InverseProperty(nameof(Order.Customer))]
    public virtual ICollection<Order> Orders { get; set; }
  }
}

Car实体一样,这段代码仍然有一些问题需要解决,并且必须添加所拥有的实体。导航属性需要JsonIgnore属性,反向属性属性需要用Navigation后缀更新,类型被更改为初始化的IEnumerable<T>,并且virtual修饰符被删除。更新代码以匹配以下内容:

[JsonIgnore]
[InverseProperty(nameof(CreditRisk.CustomerNavigation))]
public IEnumerable<CreditRisk> CreditRisks { get; set; } = new List<CreditRisk>();

[JsonIgnore]
[InverseProperty(nameof(Order.CustomerNavigation))]
public IEnumerable<Order> Orders { get; set; } = new List<Order>();

最后的更改是添加拥有的属性。该关系将在 Fluent API 中进一步配置。

public Person PersonalInformation { get; set; } = new Person();

这就完成了更新的Customer实体。

制作实体

Makes表被搭建到一个名为Make的实体类上。更新using语句以匹配以下内容:

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using AutoLot.Models.Entities.Base;
using Microsoft.EntityFrameworkCore;

BaseEntity继承并删除IdTimeStamp属性。删除构造函数和杂注#nullable disable并添加带有模式的Table属性。实体的当前状态如下:

namespace AutoLot.Models.Entities
{
  [Table("Makes", Schema = "dbo")]
  public partial class Make : BaseEntity
  {
    [Required]
    [StringLength(50)]
    public string Name { get; set; }
    [InverseProperty(nameof(Inventory.Make))]
    public virtual ICollection<Inventory> Inventories { get; set; }
  }
}

下面的代码显示了不可空的Name属性的初始化和Cars导航属性的更正(注意在nameof方法中从InventoryCar的变化):

[Required]
[StringLength(50)]
public string Name { get; set; } = "Ford";

[JsonIgnore]
[InverseProperty(nameof(Car.MakeNavigation))]
public IEnumerable<Car> Cars { get; set; } = new List<Car>();

这就完成了Make实体。

信用风险实体

CreditRisks表被搭建到一个名为CreditRisk的实体类上。更新using语句以匹配以下内容:

using System.ComponentModel.DataAnnotations.Schema;
using AutoLot.Models.Entities.Base;
using AutoLot.Models.Entities.Owned;

BaseEntity继承并删除IdTimeStamp属性。删除构造函数和杂注#nullable disable并添加带有模式的Table属性。删除FirstNameLastName属性,因为它们将被Person拥有的实体所取代。以下是更新后的类别代码:

namespace AutoLot.Models.Entities
{
  [Table("CreditRisks", Schema = "dbo")]
  public partial class CreditRisk : BaseEntity
  {
    public Person PersonalInformation { get; set; } = new Person();
    public int CustomerId { get; set; }

    [ForeignKey(nameof(CustomerId))]
    [InverseProperty("CreditRisks")]
    public virtual Customer Customer { get; set; }
  }
}

通过删除virtual修饰符来修复导航属性,在InverseProperty属性中使用nameof()方法,并在属性名中添加Navigation后缀。

[ForeignKey(nameof(CustomerId))]
[InverseProperty(nameof(Customer.CreditRisks))]
public Customer? CustomerNavigation { get; set; }

最后的更改是添加拥有的属性。该关系将在 Fluent API 中进一步配置。

public Person PersonalInformation { get; set; } = new Person();

这就完成了CreditRisk实体。

订单实体

Orders表被搭建到一个名为Order的实体类上。更新using语句以匹配以下内容:

using System;
using System.ComponentModel.DataAnnotations.Schema;
using AutoLot.Models.Entities.Base;
using Microsoft.EntityFrameworkCore;

BaseEntity继承并删除IdTimeStamp属性。删除构造函数和杂注#nullable disable并添加带有模式的Table属性。以下是当前代码:

namespace AutoLot.Models.Entities
{
  [Table("Orders", Schema = "dbo")]
  [Index(nameof(CarId), Name = "IX_Orders_CarId")]
  [Index(nameof(CustomerId), nameof(CarId), Name = "IX_Orders_CustomerId_CarId", IsUnique = true)]
  public partial class Order : BaseEntity
  {
    public int CustomerId { get; set; }
    public int CarId { get; set; }
    [ForeignKey(nameof(CarId))]
    [InverseProperty(nameof(Inventory.Orders))]
    public virtual Inventory Car { get; set; }
    [ForeignKey(nameof(CustomerId))]
    [InverseProperty("Orders")]
    public virtual Customer { get; set; }
    }
}

CarCustomer导航属性需要在它们的属性名后面加上Navigation后缀。Car导航属性需要从Inventory修正为Car的类型。逆属性需要nameof()方法使用Car.Orders而不是Inventory.OrdersCustomer导航属性需要使用InversePropertynameof()方法。这两个属性都需要被设置为可空,并且virtual修饰符被移除。

[ForeignKey(nameof(CarId))]
[InverseProperty(nameof(Car.Orders))]
public Car? CarNavigation { get; set; }

[ForeignKey(nameof(CustomerId))]
[InverseProperty(nameof(Customer.Orders))]
public Customer? CustomerNavigation { get; set; }

这就完成了Order实体。

Note

这时,自动手枪。模型项目应该正确构建。自动手枪。在更新ApplicationDbContext类之前,Dal 项目不会构建。

SeriLogEntry 实体

数据库需要一个附加的表来保存日志记录。第八部分中的 ASP.NET 核心项目将使用 SeriLog 日志框架,其中一个选项是将日志记录写到 SQL Server 表中。我们现在要添加这个表,因为我们知道从现在开始的几个章节中会用到它。

该表不与任何其他表相关,并且不使用BaseEntity类。在Entities文件夹中添加一个名为SeriLogEntry.cs的新类文件。此处列出了完整的代码:

using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Xml.Linq;

namespace AutoLot.Models.Entities
{
  [Table("SeriLogs", Schema = "Logging")]
  public class SeriLogEntry
  {
    [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }
    public string? Message { get; set; }
    public string? MessageTemplate { get; set; }
    [MaxLength(128)]
    public string? Level { get; set; }
    [DataType(DataType.DateTime)]
    public DateTime? TimeStamp { get; set; }
    public string? Exception { get; set; }
    public string? Properties { get; set; }
    public string? LogEvent { get; set; }
    public string? SourceContext { get; set; }
    public string? RequestPath { get; set; }
    public string? ActionName { get; set; }
    public string? ApplicationName { get; set; }
    public string? MachineName { get; set; }
    public string? FilePath { get; set; }
    public string? MemberName { get; set; }
    public int? LineNumber { get; set; }
    [NotMapped]
    public XElement? PropertiesXml => (Properties != null)? XElement.Parse(Properties):null;
  }
}

这就完成了SeriLogEntry实体。

Note

该实体中的TimeStamp属性与BaseEntity类中的TimeStamp属性不同。名称是相同的,但是在这个表中,它保存条目被记录的日期和时间(这将被配置为 SQL Server 默认值),而不是其他实体中的rowversion

应用数据库上下文

是时候更新ApplicationDbContext.cs了。首先更新using语句以匹配以下内容:

using System;
using System.Collections;
using System.Collections.Generic;
using AutoLot.Models.Entities;
using AutoLot.Models.Entities.Owned;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using AutoLot.Dal.Exceptions;

该文件以无参数的构造函数开始。删除它,因为我们不需要它。下一个构造函数接受了一个DbContextOptions对象的实例,现在可以了。DbContextChangeTracker的事件挂钩将在本章后面添加。

需要将DbSet<T>属性更新为可空,修改名称,并删除virtual修饰符。需要添加新的日志记录实体。导航到DbSet<T>属性,并将其更新为以下内容:

public DbSet<SeriLogEntry>? LogEntries { get; set; }
public DbSet<CreditRisk>? CreditRisks { get; set; }
public DbSet<Customer>? Customers { get; set; }
public DbSet<Make>? Makes { get; set; }
public DbSet<Car>? Cars { get; set; }
public DbSet<Order>? Orders { get; set; }

更新 Fluent API 代码

OnModelCreating覆盖是 Fluent API 代码的归属,它使用ModelBuilder类的一个实例来更新模型。

SeriLog 实体

这个方法的第一个变化是为实体SeriLogEntry的配置添加了流畅的 API 代码。Properties属性是 SQL Server XML 列,TimeStamp属性映射到 SQL Server 中的datetime2列,默认值设置为getdate() SQL Server 函数。在OnModelCreating方法中,添加以下代码:

modelBuilder.Entity<SeriLogEntry>(entity =>
{
  entity.Property(e => e.Properties).HasColumnType("Xml");
  entity.Property(e => e.TimeStamp).HasDefaultValueSql("GetDate()");
});

信用风险实体

下一个要更新的代码是针对CreditRisk实体的。TimeStamp列的配置块被删除,因为它是在BaseEntity中配置的。导航配置必须用新名称更新。我们还断言导航属性不为空。另一个变化是为FirstNameLastName配置所拥有实体的属性到列名的映射,并为FullName属性添加计算值。下面是更新后的CreditRisk实体块,更改以粗体突出显示:

modelBuilder.Entity<CreditRisk>(entity =>
{
  entity.HasOne(d => d.CustomerNavigation)
      .WithMany(p => p!.CreditRisks)
      .HasForeignKey(d => d.CustomerId)
      .HasConstraintName("FK_CreditRisks_Customers");

  entity.OwnsOne(o => o.PersonalInformation,
    pd =>
    {
      pd.Property<string>(nameof(Person.FirstName))
           .HasColumnName(nameof(Person.FirstName))
           .HasColumnType("nvarchar(50)");
      pd.Property<string>(nameof(Person.LastName))
           .HasColumnName(nameof(Person.LastName))
           .HasColumnType("nvarchar(50)");
      pd.Property(p => p.FullName)
           .HasColumnName(nameof(Person.FullName))
           .HasComputedColumnSql("[LastName] + ', ' + [FirstName]");
    });
});

客户实体

下一个要更新的代码是针对Customer实体的。删除TimeStamp代码,并配置所拥有实体的属性。

modelBuilder.Entity<Customer>(entity =>
{
  entity.OwnsOne(o => o.PersonalInformation,
     pd =>
     {
                        pd.Property(p => p.FirstName).HasColumnName(nameof(Person.FirstName));
                        pd.Property(p => p.LastName).HasColumnName(nameof(Person.LastName));
                        pd.Property(p => p.FullName)
                            .HasColumnName(nameof(Person.FullName))
                            .HasComputedColumnSql("[LastName] + ', ' + [FirstName]");
     });
});

制作实体

对于Make实体,更新配置块以删除TimeStamp,并添加代码以限制删除具有依赖实体的实体。

modelBuilder.Entity<Make>(entity =>
{
  entity.HasMany(e => e.Cars)
      .WithOne(c => c.MakeNavigation!)
      .HasForeignKey(k => k.MakeId)
      .OnDelete(DeleteBehavior.Restrict)
      .HasConstraintName("FK_Make_Inventory");
});

订单实体

对于Order实体,更新导航属性名称并断言反向属性不为空。不再限制删除,而是将CustomerOrders的关系设置为级联删除。

modelBuilder.Entity<Order>(entity =>
{
  entity.HasOne(d => d.CarNavigation)
     .WithMany(p => p!.Orders)
     .HasForeignKey(d => d.CarId)
     .OnDelete(DeleteBehavior.ClientSetNull)
     .HasConstraintName("FK_Orders_Inventory");

  entity.HasOne(d => d.CustomerNavigation)
     .WithMany(p => p!.Orders)
     .HasForeignKey(d => d.CustomerId)
     .OnDelete(DeleteBehavior.Cascade)
     .HasConstraintName("FK_Orders_Customers");
});

Order表的CarNavigation属性上设置一个查询过滤器,过滤掉不可驾驶的汽车。请注意,这段代码与前面的代码不在同一个块中。分离它没有技术上的理由;在单独的块中设置配置是一种替代语法。

modelBuilder.Entity<Order>().HasQueryFilter(e => e.CarNavigation!.IsDrivable);

汽车实体

搭建的类包含了Inventory类的配置。需要改成Car类。可以删除TimeStamp,导航属性配置保留对MakeNavigationCars属性名称的更新。该实体得到一个查询过滤器,默认设置为只显示可驾驶的汽车,并将属性IsDrivable的默认值设置为true。更新代码以匹配以下内容:

modelBuilder.Entity<Car>(entity =>
{
  entity.HasQueryFilter(c => c.IsDrivable);
  entity.Property(p => p.IsDrivable).HasField("_isDrivable").HasDefaultValue(true);

  entity.HasOne(d => d.MakeNavigation)
    .WithMany(p => p.Cars)
    .HasForeignKey(d => d.MakeId)
    .OnDelete(DeleteBehavior.ClientSetNull)
    .HasConstraintName("FK_Make_Inventory");
});

自定义例外

异常处理的一个常见模式是捕捉系统异常(和/或 EF 核心异常,如本例所示),记录异常,然后抛出一个自定义异常。如果自定义异常在上游方法中被捕获,开发人员知道该异常已经被记录,只需要在他们的代码中对该异常做出适当的反应。

在 AutoLot 中创建一个名为Exceptions的新目录。Dal 项目。在该目录中,创建四个新的类文件:CustomException.csCustomConcurrencyException.csCustomDbUpdateException.csCustomRetryLimitExceededException.cs。以下清单显示了所有四个文件:

//CustomException.cs
using System;
namespace AutoLot.Dal.Exceptions
{
  public class CustomException : Exception
  {
    public CustomException() {}
    public CustomException(string message) : base(message) { }
    public CustomException(string message, Exception innerException)
            : base(message, innerException) { }
  }
}

//CustomConcurrencyException.cs
using Microsoft.EntityFrameworkCore;
namespace AutoLot.Dal.Exceptions
{
  public class CustomConcurrencyException : CustomException
  {
    public CustomConcurrencyException() { }
    public CustomConcurrencyException(string message) : base(message) { }
    public CustomConcurrencyException(
      string message, DbUpdateConcurrencyException innerException)
            : base(message, innerException) { }
  }
}

//CustomDbUpdateException.cs
using Microsoft.EntityFrameworkCore;
namespace AutoLot.Dal.Exceptions
{
  public class CustomDbUpdateException : CustomException
  {
    public CustomDbUpdateException() { }
    public CustomDbUpdateException(string message) : base(message) { }
    public CustomDbUpdateException(
      string message, DbUpdateException innerException)
            : base(message, innerException) { }
  }
}

//CustomRetryLimitExceededException.cs
using System;
using Microsoft.EntityFrameworkCore.Storage;

namespace AutoLot.Dal.Exceptions
{
  public class CustomRetryLimitExceededException : CustomException
  {
    public CustomRetryLimitExceededException() { }
    public CustomRetryLimitExceededException(string message)
        : base(message) { }
    public CustomRetryLimitExceededException(
      string message, RetryLimitExceededException innerException)
        : base(message, innerException) { }
  }
}

Note

自定义异常处理在第七章中有详细介绍。

重写 SaveChanges 方法

如前一章所述,基类DbContext上的SaveChanges()方法将数据更改、添加和删除保存到数据库中。重写该方法可以将异常处理封装在一个地方。定制异常就绪后,将AutoLot.Dal.Exceptions using语句添加到ApplicationDbContext类的顶部。接下来,向SaveChanges()方法添加以下覆盖:

public override int SaveChanges()
{
  try
  {
    return base.SaveChanges();
  }
  catch (DbUpdateConcurrencyException ex)
  {
    //A concurrency error occurred
    //Should log and handle intelligently
    throw new CustomConcurrencyException("A concurrency error happened.", ex);
  }
  catch (RetryLimitExceededException ex)
  {
    //DbResiliency retry limit exceeded
    //Should log and handle intelligently
    throw new CustomRetryLimitExceededException("There is a problem with SQl Server.", ex);
  }
  catch (DbUpdateException ex)
  {
    //Should log and handle intelligently
    throw new CustomDbUpdateException("An error occurred updating the database", ex);
  }
  catch (Exception ex)
  {
    //Should log and handle intelligently
    throw new CustomException("An error occurred updating the database", ex);
  }
}

处理 DbContext 和 ChangeTracker 事件

导航到ApplicationDbContext的构造函数,添加上一章讨论的三个DbContext事件。

public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
  : base(options)
{
  base.SavingChanges += (sender, args) =>
  {
    Console.WriteLine($"Saving changes for {((ApplicationDbContext)sender)!.Database!.GetConnectionString()}");
  };
  base.SavedChanges += (sender, args) =>
  {
    Console.WriteLine($"Saved {args!.EntitiesSavedCount} changes for {((ApplicationDbContext)sender)!.Database!.GetConnectionString()}");
  };
  base.SaveChangesFailed += (sender, args) =>
  {
    Console.WriteLine($"An exception occurred! {args.Exception.Message} entities");
  };
}

接下来,为ChangeTracker StateChangedTracked事件添加处理程序。

public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
  : base(options)
{
...
  ChangeTracker.Tracked += ChangeTracker_Tracked;
  ChangeTracker.StateChanged += ChangeTracker_StateChanged;
}

Tracked事件参数保存对触发事件的实体的引用,以及它是来自查询(从数据库加载)还是以编程方式添加的。在ApplicationDbContext中添加以下事件处理程序:

private void ChangeTracker_Tracked(object? sender, EntityTrackedEventArgs e)
{
  var source = (e.FromQuery) ? "Database" : "Code";
  if (e.Entry.Entity is Car c)
  {
    Console.WriteLine($"Car entry {c.PetName} was added from {source}");
  }
}

当被跟踪实体的状态改变时,触发StateChanged事件。该事件的一个用途是审计。在下面的事件处理程序中,如果实体的NewStateUnchanged,则检查OldState以查看实体是否被添加或修改。将以下事件处理程序添加到ApplicationDbContext中:

private void ChangeTracker_StateChanged(object? sender, EntityStateChangedEventArgs e)
{
  if (e.Entry.Entity is not Car c)
  {
    return;
  }
  var action = string.Empty;
  Console.WriteLine($"Car {c.PetName} was {e.OldState} before the state changed to {e.NewState}");
  switch (e.NewState)
  {
    case EntityState.Unchanged:
      action = e.OldState switch
      {
        EntityState.Added => "Added",
        EntityState.Modified => "Edited",
        _ => action
      };
      Console.WriteLine($"The object was {action}");
      break;
  }
}

创建迁移并更新数据库

在本章的这一点上,两个项目都编译好了,我们准备创建另一个迁移来更新数据库。在AutoLot.Dal项目目录中输入以下命令(每个命令必须在一行中输入):

dotnet ef migrations add UpdatedEntities -o EfStructures\Migrations -c  AutoLot.Dal.EfStructures.ApplicationDbContext

dotnet ef database update UpdatedEntities -c AutoLot.Dal.EfStructures.ApplicationDbContext

添加数据库视图和存储过程

数据库还有两个变化。第一个是添加章节 21 中的GetPetName存储过程,第二个是添加一个数据库视图,该视图将Orders表与CustomerCarMake细节结合在一起。

添加 MigrationHelpers 类

我们使用迁移来创建存储过程和视图,这需要手动编写迁移代码。这样做的原因(而不是仅仅打开 Azure Data Studio 并运行 T-SQL 代码)是为了将所有的数据库配置放在一个进程中。当所有内容都包含在迁移中时,对dotnet ef database update的一次调用就可以确保数据库是最新的,包括 EF 核心配置和定制 SQL。

当没有任何模型变化时调用dotnet migrations add命令仍然会用空的Up()Down()方法创建带有正确时间戳的迁移文件。执行以下操作创建空迁移(但不应用迁移):

dotnet ef migrations add SQL -o EfStructures\Migrations -c AutoLot.Dal.EfStructures.ApplicationDbContext

现在,在AutoLot.Dal项目的EfStructures文件夹中添加一个名为MigrationHelpers.cs的新文件。为Microsoft.EntityFrameworkCore.Migrations添加一条using语句,创建类publicstatic,并添加以下方法,这些方法使用MigrationBuilder对数据库执行 SQL 语句:

namespace AutoLot.Dal.EfStructures
{
  public static class MigrationHelpers
  {
    public static void CreateSproc(MigrationBuilder migrationBuilder)
    {
      migrationBuilder.Sql($@"
          exec (N'
          CREATE PROCEDURE [dbo].[GetPetName]
              @carID int,
              @petName nvarchar(50) output
          AS
          SELECT @petName = PetName from dbo.Inventory where Id = @carID
      ')");
    }
    public static void DropSproc(MigrationBuilder migrationBuilder)
    {
      migrationBuilder.Sql("DROP PROCEDURE [dbo].[GetPetName]");
    }

    public static void CreateCustomerOrderView(MigrationBuilder migrationBuilder)
    {
      migrationBuilder.Sql($@"
          exec (N'
          CREATE VIEW [dbo].[CustomerOrderView]
          AS
          SELECT dbo.Customers.FirstName, dbo.Customers.LastName,
             dbo.Inventory.Color, dbo.Inventory.PetName, dbo.Inventory.IsDrivable,
             dbo.Makes.Name AS Make
          FROM   dbo.Orders
          INNER JOIN dbo.Customers ON dbo.Orders.CustomerId = dbo.Customers.Id
          INNER JOIN dbo.Inventory ON dbo.Orders.CarId = dbo.Inventory.Id
          INNER JOIN dbo.Makes ON dbo.Makes.Id = dbo.Inventory.MakeId
      ')");
    }
    public static void DropCustomerOrderView(MigrationBuilder migrationBuilder)
    {
      migrationBuilder.Sql("EXEC (N' DROP VIEW [dbo].[CustomerOrderView] ')");
    }
  }
}

更新并应用迁移

对于每个 SQL Server 对象,MigrationHelpers类有两个方法:一个创建对象,一个删除对象。回想一下,当应用迁移时,执行Up()方法,当回滚迁移时,执行Down()方法。创建静态方法进入迁移的Up()方法,删除方法进入迁移的Down()方法。当应用此迁移时,会创建两个 SQL Server 对象,当迁移回滚时,会删除这两个 SQL Server 对象。以下是更新后的迁移代码列表:

namespace AutoLot.Dal.EfStructures.Migrations
{
  public partial class SQL : Migration
  {
    protected override void Up(MigrationBuilder migrationBuilder)
    {
      MigrationHelpers.CreateSproc(migrationBuilder);
      MigrationHelpers.CreateCustomerOrderView(migrationBuilder);
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
      MigrationHelpers.DropSproc(migrationBuilder);
      MigrationHelpers.DropCustomerOrderView(migrationBuilder);
    }
  }
}

如果为了运行初始迁移而删除了数据库,则可以应用该迁移并继续。通过执行以下命令来应用迁移:

dotnet ef database update -c AutoLot.Dal.EfStructures.ApplicationDbContext

如果第一次迁移时没有删除数据库,则该过程已经存在,无法创建。简单的解决方法是注释掉在Up()方法中创建存储过程的调用,如下所示:

protected override void Up(MigrationBuilder migrationBuilder)
{
//  MigrationHelpers.CreateSproc(migrationBuilder);
  MigrationHelpers.CreateCustomerOrderView(migrationBuilder);
}

在第一次应用这个迁移之后,取消对该行的注释,一切都会正常进行。当然,另一种选择是从数据库中删除存储过程,然后应用迁移。这确实打破了“一个地方更新”的模式,但这是从数据库优先到代码优先的过渡的一部分。

Note

您也可以编写代码,首先检查一个对象是否存在,如果已经存在,就删除它,但是我发现对于一个可能永远不会发生的问题来说,这样做太过分了。

添加视图模型

现在 SQL Server 视图已经就绪,是时候创建用于显示视图数据的ViewModel了。视图模型将被添加为一个Keyless DbSet<T>。这样做的好处是可以使用所有DbSet<T>集合通用的正常 LINQ 过程来查询数据。

添加视图模型

在 AutoLot 中添加一个名为ViewModels的新文件夹。模型项目。在这个文件夹中,添加一个名为CustomerOrderViewModel.cs的类,并将下面的using语句添加到文件中:

using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;

接下来,将代码更新为以下内容:

namespace AutoLot.Models.ViewModels
{
  [Keyless]
  public class CustomerOrderViewModel
  {
    public string? FirstName { get; set; }
    public string? LastName { get; set; }
    public string? Color { get; set; }
    public string? PetName { get; set; }
    public string? Make { get; set; }
    public bool? IsDrivable { get;set; }
    [NotMapped]
    public string FullDetail =>
     $"{FirstName} {LastName} ordered a {Color} {Make} named {PetName}";

    public override string ToString() => FullDetail;
  }
}

KeyLess数据注释表明这是一个处理没有主键的数据的实体,并且可以优化为只读数据(从数据库的角度来看)。前五个属性表示来自视图的数据。FullDetail属性用NotMapped数据注释来修饰。这通知 EF 核心该属性将不被包括在数据库中,也不会由于查询操作而来自数据库。EF 内核也会忽略ToString()覆盖。

将 ViewModel 添加到 ApplicationDbContext

最后一步是在ApplicationDbContext中注册和配置CustomerOrderViewModel。将AutoLot.Models.ViewModelsusing语句添加到ApplicationDbContext,然后添加DbSet<T>属性。

public virtual DbSet<CustomerOrderViewModel>? CustomerOrderViewModels { get; set; }

除了添加DbSet<T>实例,Fluent API 还将视图模型映射到 SQL Server 视图。HasNoKey() Fluent API 方法和Keyless数据注释完成同样的事情,Fluent API 方法取代了数据注释。为了清晰起见,我更喜欢保留数据注释。将以下内容添加到OnModelCreating()方法:

modelBuilder.Entity<CustomerOrderViewModel>(entity =>
{
  entity.HasNoKey().ToView("CustomerOrderView","dbo");
});

添加存储库

一种常见的数据访问设计模式是存储库模式。正如马丁·福勒( www.martinfowler.com/eaaCatalog/repository.html )所描述的,这种模式的核心是在域和数据映射层之间进行调解。拥有一个包含公共数据访问代码的通用库有助于消除代码重复。拥有从基本存储库派生的特定存储库和接口也可以很好地与 ASP.NET 核心中的依赖注入框架一起工作。

AutoLot数据访问层中的每个域实体都有一个强类型 repo 来封装所有的数据访问工作。首先,在AutoLot.Dal项目中创建一个名为Repos的文件夹来保存所有的类。

Note

下一节不打算(也不假装)对 Fowler 先生的设计模式进行字面解释。如果你对激发这个版本的原始模式感兴趣,你可以在 www.martinfowler.com/eaaCatalog/repository.html 找到更多关于存储库模式的信息。

添加 IRepo 基本接口

IRepo基本接口公开了数据访问中使用的许多常用方法。在自动 Lot 中添加新文件夹。Dal 项目命名为Repos,并在那个文件夹中,新建一个文件夹命名为Base。在Repos\Base文件夹中添加一个名为IRepo的新界面。将using语句更新如下:

using System;
using System.Collections.Generic;

下面列出了完整的界面:

namespace AutoLot.Dal.Repos.Base
{
  public interface IRepo<T>: IDisposable
  {
    int Add(T entity, bool persist = true);
    int AddRange(IEnumerable<T> entities, bool persist = true);
    int Update(T entity, bool persist = true);
    int UpdateRange(IEnumerable<T> entities, bool persist = true);
    int Delete(int id, byte[] timeStamp, bool persist = true);
    int Delete(T entity, bool persist = true);
    int DeleteRange(IEnumerable<T> entities, bool persist = true);
    T? Find(int? id);
    T? FindAsNoTracking(int id);
    T? FindIgnoreQueryFilters(int id);
    IEnumerable<T> GetAll();
    IEnumerable<T> GetAllIgnoreQueryFilters();
    void ExecuteQuery(string sql, object[] sqlParametersObjects);
    int SaveChanges();
  }
}

添加 BaseRepo

接下来,将名为BaseRepo的类添加到Repos\Base目录中。这个类将实现IRepo接口,并为特定类型的回购协议提供核心功能(接下来会介绍)。将using语句更新如下:

using System;
using System.Collections.Generic;
using System.Linq;
using AutoLot.Dal.EfStructures;
using AutoLot.Dal.Exceptions;
using AutoLot.Models.Entities.Base;
using Microsoft.EntityFrameworkCore;

使用类型T使类成为泛型,并将类型约束为BaseEntitynew(),这将类型限制为具有无参数构造函数的类。实现IRepo<T>接口,如下所示:

public abstract class BaseRepo<T> : IRepo<T> where T : BaseEntity, new()

repo 需要将一个ApplicationDbContext实例注入到构造函数中。当与 ASP.NET 核心 DI 容器一起使用时,该容器将处理上下文的生命周期。第二个构造函数将接受DbContextOptions,并需要创建一个ApplicationDbContext.的实例,该上下文需要被释放。因为这个类是抽象的,所以两个构造函数都受到保护。为公共ApplicationDbContext、两个构造函数和Dispose模式添加以下代码:

private readonly bool _disposeContext;
public ApplicationDbContext Context { get; }

protected BaseRepo(ApplicationDbContext context)
{
  Context = context;
  _disposeContext = false;
}

protected BaseRepo(DbContextOptions<ApplicationDbContext> options) : this(new ApplicationDbContext(options))
{
  _disposeContext = true;
}

public void Dispose()
{
  Dispose(true);
  GC.SuppressFinalize(this);
}
private bool _isDisposed;
protected virtual void Dispose(bool disposing)
{
  if (_isDisposed)
  {
    return;
  }

  if (disposing)
  {
    if (_disposeContext)
    {
      Context.Dispose();
    }
  }
  _isDisposed = true;
}

~BaseRepo()
{
  Dispose(false);
}

通过使用Context.Set<T>()方法可以引用ApplicationDbContextDbSet<T>属性。创建一个名为TableDbSet<T>类型的公共属性,并在初始构造函数中设置值,如下所示:

public DbSet<T> Table { get; }
protected BaseRepo(ApplicationDbContext context)
{
  Context = context;
  Table = Context.Set<T>();
  _disposeContext = false;
}

实现 SaveChanges 方法

BaseRepo有一个SaveChanges()调用被覆盖的SaveChanges()方法,该方法演示了定制异常模式。将以下代码添加到BaseRepo类中:

public int SaveChanges()
{
  try
  {
    return Context.SaveChanges();
  }
  catch (CustomException ex)
  {
    //Should handle intelligently - already logged
    throw;
  }
  catch (Exception ex)
  {
    //Should log and handle intelligently
    throw new CustomException("An error occurred updating the database", ex);
  }
}

实现常见的读取方法

接下来的一系列方法使用 LINQ 语句返回记录。Find()方法获取主键值并首先搜索ChangeTracker。如果实体已经被跟踪,则返回被跟踪的实例。如果没有,则从数据库中检索记录。

public virtual T? Find(int? id) => Table.Find(id);

两个额外的Find()方法扩展了Find()基本方法。下一个方法演示了使用AsNoTrackingWithIdentityResolution()检索记录,但不将其添加到ChangeTracker中。将以下代码添加到类中:

public virtual T? FindAsNoTracking(int id) =>
  Table.AsNoTrackingWithIdentityResolution().FirstOrDefault(x => x.Id == id);

下一个变化是从实体中移除查询过滤器,然后使用简写版本(跳过Where()方法)来获取FirstOrDefault()。将以下内容添加到类中:

public T? FindIgnoreQueryFilters(int id) =>
  Table.IgnoreQueryFilters().FirstOrDefault(x => x.Id == id);

GetAll()方法返回表中的所有记录。第一个按数据库顺序检索它们,第二个轮流检索任何查询过滤器。

public virtual IEnumerable<T> GetAll() => Table;
public virtual IEnumerable<T> GetAllIgnoreQueryFilters()
  => Table.IgnoreQueryFilters();

ExecuteQuery()方法用于执行存储过程:

public void ExecuteQuery(string sql, object[] sqlParametersObjects)
  => Context.Database.ExecuteSqlRaw(sql, sqlParametersObjects);

Add、Update 和 Delete 方法

要添加的下一个代码块包装了特定DbSet<T>属性上匹配的Add()Update()Remove()方法。persist参数决定了当调用Add() / Update() / Remove()存储库方法时,repo 是否立即执行SaveChanges()。所有的方法都被标记为virtual以允许下游覆盖。将以下代码添加到您的类中:

public virtual int Add(T entity, bool persist = true)
{
  Table.Add(entity);
  return persist ? SaveChanges() : 0;
}
public virtual int AddRange(IEnumerable<T> entities, bool persist = true)
{
  Table.AddRange(entities);
  return persist ? SaveChanges() : 0;
}
public virtual int Update(T entity, bool persist = true)
{
  Table.Update(entity);
  return persist ? SaveChanges() : 0;
}
public virtual int UpdateRange(IEnumerable<T> entities, bool persist = true)
{
  Table.UpdateRange(entities);
  return persist ? SaveChanges() : 0;
}
public virtual int Delete(T entity, bool persist = true)
{
  Table.Remove(entity);
  return persist ? SaveChanges() : 0;
}
public virtual int DeleteRange(IEnumerable<T> entities, bool persist = true)
{
  Table.RemoveRange(entities);
  return persist ? SaveChanges() : 0;
}

还有一个不遵循相同模式的Delete()方法。这种方法使用EntityState来执行删除操作,这在 ASP.NET 核心操作中经常使用,以减少网络流量。这里列出了:

public int Delete(int id, byte[] timeStamp, bool persist = true)
{
  var entity = new T {Id = id, TimeStamp = timeStamp};
  Context.Entry(entity).State = EntityState.Deleted;
  return persist ? SaveChanges() : 0;
}

这就结束了BaseRepo类,现在是时候构建特定于实体的回购协议了。

实体特定的回购接口

每个实体都有一个从BaseRepo<T>派生的强类型存储库和一个实现IRepo<T>的接口。在 AutoLot 中的Repos目录下添加一个名为Interfaces的新文件夹。Dal 项目。在这个新目录中,添加五个接口。

  • ICarRepo.cs

  • ICreditRiskRepo.cs

  • ICustomerRepo.cs

  • IMakelRepo.cs

  • IOrderRepo.cs

接下来的部分将完成这些界面。

汽车存储库接口

打开ICarRepo.cs界面。将以下using语句添加到文件的顶部:

using System.Collections.Generic;
using AutoLot.Models.Entities;
using AutoLot.Dal.Repos.Base;

将界面更改为public和柠檬IRepo<Category>如下:

namespace AutoLot.Dal.Repos.Interfaces
{
  public interface ICarRepo : IRepo<Car>
  {
    IEnumerable<Car> GetAllBy(int makeId);
    string GetPetName(int id);
  }
}

信贷风险界面

打开ICreditRiskRepo.cs界面。除了在BaseRepo.中提供的功能之外,该接口不添加任何功能。将代码更新如下:

using AutoLot.Models.Entities;
using AutoLot.Dal.Repos.Base;
namespace AutoLot.Dal.Repos.Interfaces
{
  public interface ICreditRiskRepo : IRepo<CreditRisk>
  {
  }
}

客户存储库界面

打开ICustomerRepo.cs界面。除了在BaseRepo.中提供的功能之外,该接口不添加任何功能。将代码更新如下:

using AutoLot.Models.Entities;
using AutoLot.Dal.Repos.Base;
namespace AutoLot.Dal.Repos.Interfaces
{
  public interface ICustomerRepo : IRepo<Customer>
  {
  }
}

创建存储库接口

打开IMakeRepo.cs界面。除了在BaseRepo.中提供的功能之外,该接口不添加任何功能。将代码更新如下:

using AutoLot.Models.Entities;
using AutoLot.Dal.Repos.Base;
namespace AutoLot.Dal.Repos.Interfaces
{
  public interface IMakeRepo : IRepo<Make>
  {
  }
}

订单存储库界面

打开IOrderRepo.cs界面。将以下using语句添加到文件的顶部:

using System.Collections.Generic;
using System.Linq;
using AutoLot.Models.Entities;
using AutoLot.Dal.Repos.Base;
using AutoLot.Models.ViewModels;

将界面更改为public和柠檬IRepo<Order>如下:

namespace AutoLot.Dal.Repos.Interfaces
{
  public interface IOrderRepo : IRepo<Order>
  {
    IQueryable<CustomerOrderViewModel> GetOrdersViewModel();
  }
}

这就完成了接口,因为所有必需的 API 端点都包含在基类中。

实现特定于实体的存储库

实现的存储库从基类中获得大部分功能。本节介绍添加到基本存储库中或从基本存储库中覆盖的功能。在自动车床的Repos目录中。Dal 项目,添加五个回购类。

  • CarRepo.cs

  • CreditRiskRepo.cs

  • CustomerRepo.cs

  • MakeRepo.cs

  • OrderRepo.cs

接下来的部分完成了存储库。

汽车仓库

打开CarRepo.cs类并将以下using语句添加到文件的顶部:

using System.Collections.Generic;
using System.Data;
using System.Linq;
using AutoLot.Dal.EfStructures;
using AutoLot.Models.Entities;
using AutoLot.Dal.Repos.Base;
using AutoLot.Dal.Repos.Interfaces;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;

将类改为public,继承BaseRepo<Car>,实现ICarRepo

namespace AutoLot.Dal.Repos
{
  public class CarRepo : BaseRepo<Car>, ICarRepo
  {
  }
}

每个存储库都必须实现来自BaseRepo的两个构造函数。

public CarRepo(ApplicationDbContext context) : base(context)
{
}
internal CarRepo(DbContextOptions<ApplicationDbContext> options) : base(options)
{
}

GetAll()GetAllIgnoreQueryFilters()添加覆盖以包含MakeNavigation属性,并按PetName值排序。

public override IEnumerable<Car> GetAll()
  => Table
            .Include(c => c.MakeNavigation)
            .OrderBy(o => o.PetName);

public override IEnumerable<Car> GetAllIgnoreQueryFilters()
  => Table
            .Include(c => c.MakeNavigation)
            .OrderBy(o => o.PetName)
            .IgnoreQueryFilters();

实现GetAllBy()方法。此方法必须在执行前对上下文设置查询过滤器。包括Make导航属性并按PetName值排序。

public IEnumerable<Car> GetAllBy(int makeId)
{
  return Table
    .Where(x => x.MakeId == makeId)
    .Include(c => c.MakeNavigation)
    .OrderBy(c => c.PetName);
}

Find()添加一个覆盖,以包含MakeNavigation属性并忽略查询过滤器。

public override Car? Find(int? id)
  => Table
        .IgnoreQueryFilters()
        .Where(x => x.Id == id)
        .Include(m => m.MakeNavigation)
        .FirstOrDefault();

添加使用存储过程获取汽车的PetName值的方法。

public string GetPetName(int id)
{
  var parameterId = new SqlParameter
  {
    ParameterName = "@carId",
    SqlDbType = SqlDbType.Int,
    Value = id,
  };

  var parameterName = new SqlParameter
  {
    ParameterName = "@petName",
    SqlDbType = SqlDbType.NVarChar,
    Size = 50,
    Direction = ParameterDirection.Output
  };

  _ = Context.Database
    .ExecuteSqlRaw("EXEC [dbo].[GetPetName] @carId, @petName OUTPUT",parameterId, parameterName);
  return (string)parameterName.Value;
}

信用风险库

打开CreditRiskRepo.cs类并将以下using语句添加到文件的顶部:

using AutoLot.Dal.EfStructures;
using AutoLot.Dal.Models.Entities;
using AutoLot.Dal.Repos.Base;
using AutoLot.Dal.Repos.Interfaces;
using Microsoft.EntityFrameworkCore;

将类改为public,从BaseRepo<CreditRisk>继承,实现ICreditRiskRepo,并添加两个必需的构造函数。

namespace AutoLot.Dal.Repos
{
  public class CreditRiskRepo : BaseRepo<CreditRisk>, ICreditRiskRepo
  {
    public CreditRiskRepo(ApplicationDbContext context) : base(context)
    {
    }
    internal CreditRiskRepo(
      DbContextOptions<ApplicationDbContext> options)
    : base(options)
    {
    }
  }
}

客户存储库

打开CustomerRepo.cs类并将以下using语句添加到文件的顶部:

using System.Collections.Generic;
using System.Linq;
using AutoLot.Dal.EfStructures;
using AutoLot.Dal.Models.Entities;
using AutoLot.Dal.Repos.Base;
using AutoLot.Dal.Repos.Interfaces;
using Microsoft.EntityFrameworkCore;

将类改为public,从BaseRepo<Customer>继承,实现ICustomerRepo,并添加两个必需的构造函数。

namespace AutoLot.Dal.Repos
{
  public class CustomerRepo : BaseRepo<Customer>, ICustomerRepo
  {
    public CustomerRepo(ApplicationDbContext context)
      : base(context)
    {
    }
    internal CustomerRepo(
      DbContextOptions<ApplicationDbContext> options)
      : base(options)
    {
    }
  }
}

最后一步是添加方法,该方法返回所有按LastName排序的Customer记录。将以下方法添加到类中:

public override IEnumerable<Customer> GetAll()
  => Table
      .Include(c => c.Orders)
      .OrderBy(o => o.PersonalInformation.LastName);

制作存储库

打开MakeRepo.cs类并将以下using语句添加到文件的顶部:

using System.Collections.Generic;
using System.Linq;
using AutoLot.Dal.EfStructures;
using AutoLot.Dal.Models.Entities;
using AutoLot.Dal.Repos.Base;
using AutoLot.Dal.Repos.Interfaces;
using Microsoft.EntityFrameworkCore;

将类改为public,从BaseRepo<Make>继承,实现IMakeRepo,并添加两个必需的构造函数。

namespace AutoLot.Dal.Repos
{
  public class MakeRepo : BaseRepo<Make>, IMakeRepo
  {
    public MakeRepo(ApplicationDbContext context)
      : base(context)
    {
    }

    internal MakeRepo(
      DbContextOptions<ApplicationDbContext> options)
      : base(options)
    {
    }
  }
}

要覆盖的最后一个方法是GetAll()方法,按名称对Make值进行排序。

public override IEnumerable<Make> GetAll()
  => Table.OrderBy(m => m.Name);
public override IEnumerable<Make> GetAllIgnoreQueryFilters()
  => Table.IgnoreQueryFilters().OrderBy(m => m.Name);

订单存储库

打开OrderRepo.cs类并将以下using语句添加到文件的顶部:

using AutoLot.Dal.EfStructures;
using AutoLot.Dal.Models.Entities;
using AutoLot.Dal.Repos.Base;
using AutoLot.Dal.Repos.Interfaces;
using Microsoft.EntityFrameworkCore;

将类改为public,继承BaseRepo<Order>,实现IOrderRepo

namespace AutoLot.Dal.Repos
{
  public class OrderRepo : BaseRepo<Order>, IOrderRepo
  {
    public OrderRepo(ApplicationDbContext context)
      : base(context)
    {
    }

    internal OrderRepo(
      DbContextOptions<ApplicationDbContext> options)
      : base(options)
    {
    }
  }
}

要实现的最后一个方法是GetOrderViewModel()方法,它从数据库视图返回一个IQueryable<CustomOrderViewModel>

public IQueryable<CustomerOrderViewModel> GetOrdersViewModel()
{
  return Context.CustomerOrderViewModels!.AsQueryable();
}

这就完成了所有的存储库。下一节将创建删除、创建和播种数据库的代码。

程序化数据库和迁移处理

DbContextDatabase属性提供了删除和创建数据库以及运行所有迁移的编程方法。表 23-1 描述了与这些操作相关的方法。

表 23-1。

以编程方式使用数据库

|

数据库成员

|

生命的意义

| | --- | --- | | EnsureDeleted | 如果数据库存在,则删除该数据库。如果不存在,则不执行任何操作。 | | Ensure-created | 如果数据库不存在,则创建数据库。如果有也不做任何事。基于从DbSet<T>属性可到达的类创建表和列。不应用任何迁移。**注意:**这不应与迁移结合使用。 | | Migrate | 如果数据库不存在,则创建数据库。将所有迁移应用于数据库。 |

如表中所述,如果数据库不存在,EnsureCreated()方法将创建数据库,然后基于实体模型创建表、列和索引。它不适用于任何迁移。如果您正在使用迁移(就像我们一样),这将在处理数据库时出现错误,并且您将不得不欺骗 EF Core(就像我们之前所做的那样)来相信迁移已经被应用。您还必须手动将任何自定义 SQL 对象应用到数据库。当您处理迁移时,总是使用Migrate()方法以编程方式创建数据库,而不是使用EnsureCreated()方法。

删除、创建和清理数据库

在开发过程中,删除并重新创建开发数据库,然后用示例数据为其播种可能是有益的。这就创造了一个环境,在这个环境中,测试(手动或自动的)可以被执行,而不用担心由于更改数据而破坏其他测试。在 AutoLot 中创建一个名为Initialization的新文件夹。Dal 项目。在这个文件夹中,创建一个名为SampleDataInitializer.cs的新类。在文件的顶部,将using语句更新为以下内容:

using System;
using System.Collections.Generic;
using System.Linq;
using AutoLot.Dal.EfStructures;
using AutoLot.Models.Entities;
using AutoLot.Models.Entities.Base;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;

如下所示创建类publicstatic:

namespace AutoLot.Dal.Initialization
{
  public static class SampleDataInitializer
  {
  }
}

创建一个名为DropAndCreateDatabase的方法,该方法将ApplicationDbContext的实例作为单个参数。该方法使用ApplicationDbContextDatabase属性首先删除数据库(使用EnsureDeleted()方法),然后创建数据库(使用Migrate()方法)。

public static void DropAndCreateDatabase(ApplicationDbContext context)
{
  context.Database.EnsureDeleted();
  context.Database.Migrate();
}

创建另一个名为ClearData()的方法,删除数据库中的所有数据,并重置每个表的主键的标识值。该方法遍历域实体列表,并使用DbContext Model属性获取每个实体映射到的模式和表名。然后,它执行一个delete语句,并使用DbContext Database属性上的ExecuteSqlRaw()方法重置每个表的标识。

internal static void ClearData(ApplicationDbContext context)
{
  var entities = new[]
  {
    typeof(Order).FullName,
    typeof(Customer).FullName,
    typeof(Car).FullName,
    typeof(Make).FullName,
    typeof(CreditRisk).FullName
  };
  foreach (var entityName in entities)
  {
    var entity = context.Model.FindEntityType(entityName);
    var tableName = entity.GetTableName();
    var schemaName = entity.GetSchema();
    context.Database.ExecuteSqlRaw($"DELETE FROM {schemaName}.{tableName}");
    context.Database.ExecuteSqlRaw($"DBCC CHECKIDENT (\"{schemaName}.{tableName}\", RESEED, 1);");
  }
}

Note

应该小心使用数据库外观的ExecuteSqlRaw()方法,以防止潜在的 SQL 注入攻击。

现在,您可以删除并创建数据库并清除数据,接下来是时候创建添加示例数据的方法了。

数据初始化

我们将构建自己的数据播种系统,可以按需运行。第一步是创建样本数据,然后将方法添加到用于将样本数据加载到数据库的SampleDataInitializer中。

创建示例数据

将名为SampleData.cs的新文件添加到Initialization文件夹中。创建publicstatic类,并将using语句更新如下:

using System.Collections.Generic;
using AutoLot.Dal.Entities;
using AutoLot.Dal.Entities.Owned;

namespace AutoLot.Dal.Initialization
{
  public static class SampleData
  {
  }
}

该文件由五个创建示例数据的静态方法组成。

public static List<Customer> Customers => new()
{
  new() {Id = 1, PersonalInformation = new() {FirstName = "Dave", LastName = "Brenner"}},
  new() {Id = 2, PersonalInformation = new() {FirstName = "Matt", LastName = "Walton"}},
  new() {Id = 3, PersonalInformation = new() {FirstName = "Steve", LastName = "Hagen"}},
  new() {Id = 4, PersonalInformation = new() {FirstName = "Pat", LastName = "Walton"}},
  new() {Id = 5, PersonalInformation = new() {FirstName = "Bad", LastName = "Customer"}},
};

public static List<Make> Makes => new()
{
  new() {Id = 1, Name = "VW"},
  new() {Id = 2, Name = "Ford"},
  new() {Id = 3, Name = "Saab"},
  new() {Id = 4, Name = "Yugo"},
  new() {Id = 5, Name = "BMW"},
  new() {Id = 6, Name = "Pinto"},
};

public static List<Car> Inventory => new()
{
  new() {Id = 1, MakeId = 1, Color = "Black", PetName = "Zippy"},
  new() {Id = 2, MakeId = 2, Color = "Rust", PetName = "Rusty"},
  new() {Id = 3, MakeId = 3, Color = "Black", PetName = "Mel"},
  new() {Id = 4, MakeId = 4, Color = "Yellow", PetName = "Clunker"},
  new() {Id = 5, MakeId = 5, Color = "Black", PetName = "Bimmer"},
  new() {Id = 6, MakeId = 5, Color = "Green", PetName = "Hank"},
  new() {Id = 7, MakeId = 5, Color = "Pink", PetName = "Pinky"},
  new() {Id = 8, MakeId = 6, Color = "Black", PetName = "Pete"},
  new() {Id = 9, MakeId = 4, Color = "Brown", PetName = "Brownie"},
  new() {Id = 10, MakeId = 1, Color = "Rust", PetName = "Lemon", IsDrivable = false},
};

public static List<Order> Orders => new()
{
  new() {Id = 1, CustomerId = 1, CarId = 5},
  new() {Id = 2, CustomerId = 2, CarId = 1},
  new() {Id = 3, CustomerId = 3, CarId = 4},
  new() {Id = 4, CustomerId = 4, CarId = 7},
  new() {Id = 5, CustomerId = 5, CarId = 10},
};

public static List<CreditRisk> CreditRisks => new()
{
  new()
  {
    Id = 1,
    CustomerId = Customers[4].Id,
    PersonalInformation = new()
    {
      FirstName = Customers[4].PersonalInformation.FirstName,
      LastName = Customers[4].PersonalInformation.LastName
    }
  }
};

加载示例数据

SampleDataInitializer类中的内部SeedData()方法将来自SampleData方法的数据添加到ApplicationDbContext的实例中,然后将数据保存到数据库中。

internal static void SeedData(ApplicationDbContext context)
{
  try
  {
    ProcessInsert(context, context.Customers!, SampleData.Customers);
    ProcessInsert(context, context.Makes!, SampleData.Makes);
    ProcessInsert(context, context.Cars!, SampleData.Inventory);
    ProcessInsert(context, context.Orders!, SampleData.Orders);
    ProcessInsert(context, context.CreditRisks!, SampleData.CreditRisks);
  }
  catch (Exception ex)
  {
    Console.WriteLine(ex);
    //Set a break point here to determine what the issues is
    throw;
  }
  static void ProcessInsert<TEntity>(
    ApplicationDbContext context,
    DbSet<TEntity> table,
    List<TEntity> records) where TEntity : BaseEntity
  {
     if (table.Any())
     {
       return;
     }
    IExecutionStrategy strategy = context.Database.CreateExecutionStrategy();
    strategy.Execute(() =>
    {
      using var transaction = context.Database.BeginTransaction();
      try
      {
        var metaData = context.Model.FindEntityType(typeof(TEntity).FullName);
        context.Database.ExecuteSqlRaw(
            $"SET IDENTITY_INSERT {metaData.GetSchema()}.{metaData.GetTableName()} ON");
        table.AddRange(records);
        context.SaveChanges();
        context.Database.ExecuteSqlRaw(
            $"SET IDENTITY_INSERT {metaData.GetSchema()}.{metaData.GetTableName()} OFF");
        transaction.Commit();
      }
      catch (Exception)
      {
        transaction.Rollback();
      }
      });
  }
}

SeedData()方法使用一个本地函数来处理数据。它首先检查表中是否有记录,如果没有,就继续处理样本数据。从数据库外观创建一个ExecutionStrategy,它用于创建一个显式事务,这是打开和关闭身份插入所需要的。记录被添加,如果全部成功,事务被提交;否则,它将回滚。

这两个方法是公共的,用于重置数据库。InitializeData()在播种之前删除并重新创建数据库,而ClearDatabase()方法只是删除所有记录,重置标识,然后播种数据。

public static void InitializeData(ApplicationDbContext context)
{
  DropAndCreateDatabase(context);
  SeedData(context);
}

public static void ClearAndReseedDatabase(ApplicationDbContext context)
{
  ClearData(context);
  SeedData(context);
}

设置试驾

我们将使用自动化集成测试,而不是创建一个客户端应用来测试完整的AutoLot数据访问层。测试将演示对数据库的创建、读取、更新和删除调用。这允许我们检查代码,而不需要创建另一个应用。本节中的每个测试都将执行一个查询(创建、读取、更新或删除),然后使用一个或多个Assert语句来验证结果是否符合预期。

创建项目

首先,我们将使用 xUnit(一个. NET 核心兼容的测试框架)建立一个集成测试平台。首先添加一个名为 AutoLot.Dal.Tests 的新 xUnit 测试项目。网芯)。

Note

单元测试旨在测试单个代码单元。我们将在本章中所做的是从技术上创建集成测试,因为我们正在测试 C# 代码 EF 内核到数据库并返回。

从命令行界面,执行以下命令:

dotnet new xunit -lang c# -n AutoLot.Dal.Tests -o .\AutoLot.Dal.Tests -f net5.0
dotnet sln .\Chapter23_AllProjects.sln add AutoLot.Dal.Tests

将以下 NuGet 包添加到AutoLot.Dal.Tests项目中:

  • Microsoft.EntityFrameworkCore

  • Microsoft.EntityFrameworkCore.SqlServer

  • Microsoft.Extensions.Configuration.Json

因为 xUnit 项目模板附带的Microsoft.NET.Test.Sdk包的版本通常落后于当前可用的版本,所以使用 NuGet 包管理器来更新所有的 NuGet 包。接下来,添加对AutoLot.ModelsAutoLot.Dal的项目引用。

如果您正在使用 CLI,请执行以下命令(注意,这些命令会删除并重新添加Microsoft.NET.Test.Sdk以确保引用最新版本):

dotnet add AutoLot.Dal.Tests package Microsoft.EntityFrameworkCore
dotnet add AutoLot.Dal.Tests package Microsoft.EntityFrameworkCore.SqlServer
dotnet add AutoLot.Dal.Tests package Microsoft.Extensions.Configuration.Json
dotnet remove AutoLot.Dal.Tests package Microsoft.NET.Test.Sdk
dotnet add AutoLot.Dal.Tests package Microsoft.NET.Test.Sdk
dotnet add AutoLot.Dal.Tests reference AutoLot.Dal
dotnet add AutoLot.Dal.Tests reference AutoLot.Models

配置项目

为了在运行时检索连接字符串,我们将使用。使用 JSON 文件的. NET 核心配置功能。将一个名为appsettings.json的 JSON 文件添加到项目中,并将您的连接字符串信息添加到以下格式的文件中(根据需要更新此处列出的连接字符串):

{
  "ConnectionStrings": {
    "AutoLot": "server=.,5433;Database=AutoLotFinal;User Id=sa;Password=P@ssw0rd;"
  }
}

更新项目文件,以便在每次生成时将设置文件复制到输出文件夹中。通过将下面的ItemGroup添加到AutoLot.Dal.Tests.csproj文件中来实现:

  <ItemGroup>
    <None Update="appsettings.json">
      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </None>
  </ItemGroup>

创建测试助手

TestHelper类将处理应用配置,并创建一个新的ApplicationDbContext实例。在项目的根中添加一个名为TestHelpers.cs的新public static类。将using声明更新如下:

using System.IO;
using AutoLot.Dal.EfStructures;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Configuration;

namespace AutoLot.Dal.Tests
{
  public static class TestHelpers
  {
  }
}

添加两个公共静态方法来创建IConfigurationApplicationDbContext类的实例。将以下代码添加到类中:

public static IConfiguration GetConfiguration() =>
  new ConfigurationBuilder()
    .SetBasePath(Directory.GetCurrentDirectory())
    .AddJsonFile("appsettings.json", true, true)
    .Build();

public static ApplicationDbContext GetContext(IConfiguration configuration)
{
  var optionsBuilder = new DbContextOptionsBuilder<ApplicationDbContext>();
  var connectionString = configuration.GetConnectionString("AutoLot");
  optionsBuilder.UseSqlServer(connectionString, sqlOptions => sqlOptions.EnableRetryOnFailure());
  return new ApplicationDbContext(optionsBuilder.Options);
}

注意对EnableRetryOnFailure()的调用(粗体)。提醒一下,这选择了 SQL Server 重试执行策略,该策略将自动重试由于暂时性错误而失败的操作。

添加另一个静态方法,该方法将使用与传入的原始方法相同的连接和事务创建一个新的ApplicationDbContext实例。这个方法展示了如何从一个现有的实例创建一个ApplicationDbContext的实例来共享连接和事务。

public static ApplicationDbContext GetSecondContext(
  ApplicationDbContext oldContext,
  IDbContextTransaction trans)
{
  var optionsBuilder = new DbContextOptionsBuilder<ApplicationDbContext>();
  optionsBuilder.UseSqlServer(
    oldContext.Database.GetDbConnection(),
    sqlServerOptions => sqlServerOptions.EnableRetryOnFailure());
  var context = new ApplicationDbContext(optionsBuilder.Options);
  context.Database.UseTransaction(trans.GetDbTransaction());
  return context;
}

添加 BaseTest 类

现在向项目添加一个名为Base的新文件夹,并向该文件夹添加一个名为BaseTest.cs的新类文件。将using声明更新如下:

using System;
using System.Data;
using AutoLot.Dal.EfStructures;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Configuration;

使类抽象并实现IDisposable。添加两个受保护的readonly属性来保存IConfigurationApplicationDbContext实例,并在virtual Dispose()方法中释放ApplicationDbContext实例。

namespace AutoLot.Dal.Tests.Base
{
  public abstract class BaseTest : IDisposable
  {
    protected readonly IConfiguration Configuration;
    protected readonly ApplicationDbContext Context;

    public virtual void Dispose()
    {
      Context.Dispose();
    }
  }
}

xUnit 测试框架提供了一种机制,在执行每个测试的之前和之后运行代码。实现IDisposable接口的测试类(称为fixture*)将在每个测试运行之前执行类构造函数(在本例中是基类构造函数和派生类构造函数)中的代码(也称为测试设置),并且在每个测试运行之后运行Dispose方法(在派生类和基类中)中的代码(也称为测试拆除)。*

添加一个受保护的构造函数,该构造函数创建一个IConfiguration的实例,并将其赋给受保护的类变量。使用配置创建一个使用TestHelper类的ApplicationDbContext实例,并将其分配给protected类变量。

protected BaseTest()
{
  Configuration = TestHelpers.GetConfiguration();
  Context = TestHelpers.GetContext(Configuration);
}

添加翻译后的测试执行助手

BaseTest类中的最后两个方法支持在事务中运行测试方法。这些方法将把一个Action委托作为单个参数,创建一个显式事务(或登记一个现有的事务),执行Action委托,然后回滚事务。我们这样做是为了让任何创建/更新/删除测试都保持数据库在测试运行之前的状态。由于ApplicationDbContext被配置为启用瞬时错误重试,整个过程必须从ApplicationDbContext.的执行策略执行

ExecuteInATransaction()使用ApplicationDbContext的单个实例执行。ExecuteInASharedTransaction()方法允许多个ApplicationDbContext实例共享一个事务。在本章的后面你会学到更多关于这些方法的知识。现在,将下面的代码添加到您的BaseTest类中:

protected void ExecuteInATransaction(Action actionToExecute)
{
  var strategy = Context.Database.CreateExecutionStrategy();
  strategy.Execute(() =>
  {
    using var trans = Context.Database.BeginTransaction();
    actionToExecute();
    trans.Rollback();
  });
}

protected void ExecuteInASharedTransaction(Action<IDbContextTransaction> actionToExecute)
{
  var strategy = Context.Database.CreateExecutionStrategy();
  strategy.Execute(() =>
  {
    using IDbContextTransaction trans =
      Context.Database.BeginTransaction(IsolationLevel.ReadUncommitted);
    actionToExecute(trans);
    trans.Rollback();
  });
}

添加 EnsureAutoLotDatabase 测试夹具类

xUnit 测试框架提供了在任何测试运行之前(称为夹具设置)和所有测试运行之后(称为夹具拆除)运行代码的机制。通常不推荐这种做法,但是在我们的例子中,我们希望确保在运行任何测试之前创建数据库并加载数据,而不是在运行每个测试之前。实现IClassFixture<T> where T: TestFixtureClass的测试类将在任何测试运行之前执行T(TestFixtureClass)的构造器代码,而Dispose()代码将在所有测试完成之后运行。

将名为EnsureAutoLotDatabaseTestFixture.cs的新类添加到Base目录中,并实现IDisposable。制作publicsealed类,增加以下using语句:

using System;
using AutoLot.Dal.Initialization;

namespace AutoLot.Dal.Tests.Base
{
  public sealed class EnsureAutoLotDatabaseTestFixture : IDisposable
  {
  }
}

构造器代码创建一个IConfiguration的实例,然后使用IConfiguration实例创建一个ApplicationDbContext的实例。接下来,它从SampleDataInitializer.调用ClearAndReseedDatabase()方法,最后一行处理上下文实例。在我们的例子中,Dispose()方法没有任何工作要做(但是需要满足IDisposable接口)。下面的清单显示了构造函数和Dispose()方法:

public EnsureAutoLotDatabaseTestFixture()
{
  var configuration =  TestHelpers.GetConfiguration();
  var context = TestHelpers.GetContext(configuration);
  SampleDataInitializer.ClearAndReseedDatabase(context);
  context.Dispose();
}

public void Dispose()
{
}

添加集成测试类

下一步是添加保存自动化测试的类。这些类别被称为测试夹具。在AutoLot.Dal.Tests文件夹中添加一个名为IntegrationTests的新文件夹,并在该文件夹中添加四个名为CarTests.csCustomerTests.csMakeTests.csOrderTests.cs的文件。

根据测试运行程序的能力,xUnit 测试在一个测试设备(类)中串行运行,但是在所有测试设备(类)中并行运行。当执行与数据库交互的集成测试时,这可能会有问题,因为测试是与单个数据库交互的。通过将测试设备添加到同一个测试集合中,可以将执行更改为跨测试设备的串行执行。测试集合是使用类上的Collection属性按名称定义的。将下面的Collection属性添加到所有四个类的顶部:

[Collection("Integration Tests")]

接下来,从BaseTest继承并在两个类中实现IClassFixture接口。更新每个类的using语句,以匹配以下内容:

//CarTests.cs
using System.Collections.Generic;
using System.Linq;
using AutoLot.Dal.Exceptions;
using AutoLot.Dal.Repos;
using AutoLot.Dal.Tests.Base;
using AutoLot.Models.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Query;
using Microsoft.EntityFrameworkCore.Storage;
using Xunit;
namespace AutoLot.Dal.Tests.IntegrationTests
{
  [Collection("Integation Tests")]
  public class CarTests : BaseTest, IClassFixture<EnsureAutoLotDatabaseTestFixture>
  {
  }
}

//CustomerTests.cs
using System.Collections.Generic;
using System;
using System.Linq;
using System.Linq.Expressions;
using AutoLot.Dal.Tests.Base;
using AutoLot.Models.Entities;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace AutoLot.Dal.Tests.IntegrationTests

{
  [Collection("Integation Tests")]
  public class CustomerTests : BaseTest, IClassFixture<EnsureAutoLotDatabaseTestFixture>
  {
  }
}

//MakeTests.cs
using System.Linq;
using AutoLot.Dal.Repos;
using AutoLot.Dal.Repos.Interfaces;
using AutoLot.Dal.Tests.Base;
using AutoLot.Models.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Xunit;
namespace AutoLot.Dal.Tests.IntegrationTests
{
  [Collection("Integation Tests")]
  public class MakeTests : BaseTest, IClassFixture<EnsureAutoLotDatabaseTestFixture>
  {
  }
}

//OrderTests.cs
using System.Linq;
using AutoLot.Dal.Repos;
using AutoLot.Dal.Repos.Interfaces;
using AutoLot.Dal.Tests.Base;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace AutoLot.Dal.Tests.IntegrationTests
{
  [Collection("Integation Tests")]
  public class OrderTests : BaseTest, IClassFixture<EnsureAutoLotDatabaseTestFixture>
  {
  }
}

对于MakeTests类,添加一个构造函数,创建一个MakeRepo的实例,并将该实例分配给一个private readonly类级别的变量。覆盖Dispose()方法,并在该方法中处置回购。

[Collection("Integration Tests")]
public class MakeTests : BaseTest, IClassFixture<EnsureAutoLotDatabaseTestFixture>
{
  private readonly IMakeRepo _repo;
  public MakeTests()
  {
    _repo = new MakeRepo(Context);
  }
  public override void Dispose()
  {
    _repo.Dispose();
  }
...
}

重复到OrderTests类,使用OrderRepo代替MakeRepo.

[Collection("Integration Tests")]
public class OrderTests : BaseTest, IClassFixture<EnsureAutoLotDatabaseTestFixture>
{
  private readonly IOrderRepo _repo;
  public OrderTests()
  {
    _repo = new OrderRepo(Context);
  }
  public override void Dispose()
  {
    _repo.Dispose();
  }
...
}

事实和理论测试方法

无参数测试方法被称为事实(并使用Fact属性)。接受参数的测试被称为理论(并使用Theory属性),并且可以运行多次迭代,将不同的值作为参数传递给测试方法。为了演示这些测试类型,在AutoLot.Dal.Tests项目中创建一个名为SampleTests.cs的新类。将using声明更新如下:

using Xunit;

namespace AutoLot.Dal.Tests
{
  public class SampleTests
  {
  }
}

要创建的第一个测试是一个Fact测试。对于Fact测试,所有值都包含在测试方法中。下面的例子测试了 3+2=5:

[Fact]
public void SimpleFactTest()
{
  Assert.Equal(5,3+2);
}

当使用Theory类型测试时,测试的值被传递到测试方法中。这些值可以来自InlineData属性、方法或类。出于我们的目的,我们将只使用InlineData属性。创建以下测试,为测试提供不同的加数和预期结果:

[Theory]
[InlineData(3,2,5)]
[InlineData(1,-1,0)]
public void SimpleTheoryTest(int addend1, int addend2, int expectedResult)
{
  Assert.Equal(expectedResult,addend1+addend2);
}

Note

有关 xUnit 测试框架的更多信息,请参考位于 https://xunit.net/ .的文档

执行测试

虽然 xUnit 测试可以从命令行执行(使用dotnet test),但是使用 Visual Studio 执行测试是更好的开发体验(在我看来)。从“测试”菜单启动测试资源管理器,以便能够运行和调试所有或选定的测试。

查询数据库

回想一下,从数据库数据创建实体实例通常涉及针对DbSet<T>属性执行 LINQ 语句。数据库提供商和 LINQ 翻译引擎将 LINQ 语句转换为 SQL,并从数据库中读取适当的数据。也可以使用原始 SQL 字符串通过FromSqlRaw()FromSqlInterpolated()方法加载数据。默认情况下,加载到DbSet<T>集合中的实体被添加到ChangeTracker中,但是可以在没有跟踪的情况下添加。无钥匙DbSet<T>收藏中加载的数据永远不会被跟踪。

如果相关实体已经加载到DbSet<T>中,EF Core 将沿着导航属性连接新的实例。例如,如果将Cars加载到DbSet<Car>集合中,然后将相关的Orders加载到同一个ApplicationDbContext实例的DbSet<Order>中,则Car.Orders导航属性将返回相关的Order实体,而不重新查询数据库。

这里演示的许多方法都有可用的异步版本。LINQ 查询的语法在结构上是相同的,所以我将只演示非同步版本。

实体状态

当通过从数据库中读取数据来创建实体时,EntityState值被设置为Unchanged

LINQ 询问

DbSet<T>集合类型实现了(在其他接口中)IQueryable<T>。这允许使用 C# LINQ 命令创建查询来从数据库中获取数据。虽然所有的 C# LINQ 语句都可以与DbSet<T>集合类型一起使用,但是一些 LINQ 语句可能不被数据库提供者支持,并且额外的 LINQ 语句由 EF Core 添加。除非语句是 LINQ 链的最后一条语句,否则无法翻译成数据库提供者的查询语言的不受支持的 LINQ 语句将引发运行时异常。如果不支持的 LINQ 语句是 LINQ 链中的最后一条语句,它将在客户端执行(在 C# 中)。

Note

这本书不是一个完整的 LINQ 参考,但只是显示了几个例子。为了获得更多 LINQ 查询的例子,微软在 https://code.msdn.microsoft.com/101-LINQ-Samples-3fb9811b 发布了 101 个 LINQ 样本。

LINQ 处决

提醒一下,当使用 LINQ 在数据库中查询实体列表时,只有在查询被迭代、转换为List<T>(或数组)或绑定到列表控件(如数据网格)时,才会执行查询。对于单记录查询,当单记录调用(First()Single()等)时,该语句立即执行。)被使用。

EF Core 5 中的新功能,您可以在大多数 LINQ 查询中调用ToQueryString()方法来检查针对数据库执行的查询。对于分割查询,ToQueryString()方法只返回将要执行的第一个查询。如果可以的话,下一节中的测试将一个变量(qs)设置为这个值,这样您就可以在调试测试的同时检查查询。

第一组测试(除非特别提到)在CustomerTests.cs类中。

获取所有记录

要获得一个表的所有记录,只需直接使用DbSet<T>属性,不需要任何 LINQ 语句。添加以下Fact:

[Fact]
public void ShouldGetAllOfTheCustomers()
{
  var qs = Context.Customers.ToQueryString();
  var customers = Context.Customers.ToList();
  Assert.Equal(5, customers.Count);
}

该语句被翻译成以下 SQL:

SELECT [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]

相同的过程用于Keyless实体,如CustomerOrderViewModel,它被配置为从CustomerOrderView获取数据。

modelBuilder.Entity<CustomerOrderViewModel>().HasNoKey().ToView("CustomerOrderView", "dbo");

视图模型的DbSet<T>实例为键控实体提供了DbSet<T>的所有查询功能。区别在于更新能力。视图模型的更改不能持久化到数据库中,而键控实体可以。将下面的测试添加到OrderTest.cs类中,以显示从视图中获取数据:

public void ShouldGetAllViewModels()
{
  var qs = Context.Orders.ToQueryString();
  var orders = Context.Orders.ToList();
  Assert.NotEmpty(orders);
  Assert.Equal(5,orders.Count);
}

该语句被翻译成以下 SQL:

SELECT [c].[Color], [c].[FirstName], [c].[IsDrivable], [c].[LastName], [c].[Make], [c].[PetName]
FROM [dbo].[CustomerOrderView] AS [c]

过滤记录

Where()方法用于过滤来自DbSet<T>.的记录。多个Where()方法可以流畅地链接起来,以动态构建查询。链接的Where()方法总是被组合成and子句。要创建一个or语句,使用相同的Where()子句。

以下测试返回姓氏以 W 开头的客户(不区分大小写):

[Fact]
public void ShouldGetCustomersWithLastNameW()
{
  IQueryable<Customer> query = Context.Customers
    .Where(x => x.PersonalInformation.LastName.StartsWith("W"));
  var qs = query.ToQueryString();
  List<Customer> customers = query.ToList();
  Assert.Equal(2, customers.Count);
}

LINQ 查询被翻译成以下 SQL:

SELECT [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]
WHERE [c].[LastName] IS NOT NULL AND ([c].[LastName] LIKE N'W%')

以下测试返回姓氏以 W (不区分大小写)开头,名字以 M (不区分大小写)开头的客户,并演示在 LINQ 查询中链接Where()方法:

[Fact]
public void ShouldGetCustomersWithLastNameWAndFirstNameM()
{
  IQueryable<Customer> query = Context.Customers
    .Where(x => x.PersonalInformation.LastName.StartsWith("W"))
    .Where(x => x.PersonalInformation.FirstName.StartsWith("M"));
  var qs = query.ToQueryString();
  List<Customer> customers = query.ToList();
  Assert.Single(customers);
}

以下测试使用单个Where()方法返回姓氏以 W (不区分大小写)开头并且名字以 M (不区分大小写)开头的客户:

[Fact]
public void ShouldGetCustomersWithLastNameWAndFirstNameM()
{
  IQueryable<Customer> query = Context.Customers
    .Where(x => x.PersonalInformation.LastName.StartsWith("W") &&
                           x.PersonalInformation.FirstName.StartsWith("M"));
  var qs = query.ToQueryString();
  List<Customer> customers = query.ToList();
  Assert.Single(customers);
}

这两个查询都被翻译成以下 SQL:

SELECT [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]
WHERE ([c].[LastName] IS NOT NULL AND ([c].[LastName] LIKE N'W%'))
AND ([c].[FirstName] IS NOT NULL AND ([c].[FirstName] LIKE N'M%'))

以下测试返回姓氏以 W (不区分大小写)开头的 H (不区分大小写)的客户:

[Fact]
public void ShouldGetCustomersWithLastNameWOrH()
{
  IQueryable<Customer> query = Context.Customers
    .Where(x => x.PersonalInformation.LastName.StartsWith("W") ||
                           x.PersonalInformation.LastName.StartsWith("H"));
  var qs = query.ToQueryString();
  List<Customer> customers = query.ToList();
  Assert.Equal(3, customers.Count);
}

这被转换成以下 SQL:

SELECT [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]
WHERE ([c].[LastName] IS NOT NULL AND ([c].[LastName] LIKE N'W%'))
OR ([c].[LastName] IS NOT NULL AND ([c].[LastName] LIKE N'H%'))

以下测试返回姓氏以 W (不区分大小写)开头的客户,姓氏以 H (不区分大小写)开头。该测试演示了使用EF.Functions.Like()方法。注意,您必须自己包含通配符(%)。

[Fact]
public void ShouldGetCustomersWithLastNameWOrH()
{
  IQueryable<Customer> query = Context.Customers
    .Where(x => EF.Functions.Like(x.PersonalInformation.LastName, "W%") ||
                            EF.Functions.Like(x.PersonalInformation.LastName, "H%"));
  var qs = query.ToQueryString();
  List<Customer> customers = query.ToList();
  Assert.Equal(3, customers.Count);
}

这被转换成下面的 SQL(注意它不检查 null):

SELECT [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]
WHERE ([c].[LastName] LIKE N'W%') OR ([c].[LastName] LIKE N'H%')

下面在CarTests.cs类中的测试使用一个Theory来测试基于MakeIdInventory表中Car记录的数量(在“全局查询过滤器”一节中介绍了IgnoreQueryFilters()方法):

[Theory]
[InlineData(1, 2)]
[InlineData(2, 1)]
[InlineData(3, 1)]
[InlineData(4, 2)]
[InlineData(5, 3)]
[InlineData(6, 1)]
public void ShouldGetTheCarsByMake(int makeId, int expectedCount)
{
  IQueryable<Car> query =
    Context.Cars.IgnoreQueryFilters().Where(x => x.MakeId == makeId);
  var qs = query.ToQueryString();
  var cars = query.ToList();
  Assert.Equal(expectedCount, cars.Count);
}

每一行InlineData都成为测试运行程序中的一个独特的测试。对于这个例子,处理了六个测试,并对数据库执行了六个查询。下面是其中一个测试的 SQL 语句(与其他测试的查询在Theory中的唯一区别是MakeId的值):

DECLARE @__makeId_0 int = 1;
SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId], [i].[PetName], [i].[TimeStamp]
FROM [dbo].[Inventory] AS [i]
WHERE [i].[MakeId] = @__makeId_0

下面的Theory测试显示了一个带有CustomerOrderViewModel的过滤查询(将测试放在OrderTests.cs类中):

[Theory]
[InlineData("Black",2)]
[InlineData("Rust",1)]
[InlineData("Yellow",1)]
[InlineData("Green",0)]
[InlineData("Pink",1)]
[InlineData("Brown",0)]
public void ShouldGetAllViewModelsByColor(string color, int expectedCount)
{
    var query = _repo.GetOrdersViewModel().Where(x=>x.Color == color);
    var qs = query.ToQueryString();
    var orders = query.ToList();
    Assert.Equal(expectedCount,orders.Count);
}

第一个InlineData测试生成的查询如下所示:

DECLARE @__color_0 nvarchar(4000) = N'Black';
SELECT [c].[Color], [c].[FirstName], [c].[IsDrivable], [c].[LastName], [c].[Make], [c].[PetName]
FROM [dbo].[CustomerOrderView] AS [c]
WHERE [c].[Color] = @__color_0

排序记录

OrderBy()OrderByDescending()方法分别设置查询的排序,升序和降序。如果需要后续排序,使用ThenBy()ThenByDescending()方法。排序显示在以下测试中:

[Fact]
public void ShouldSortByLastNameThenFirstName()
{
  //Sort by Last name then first name
  var query = Context.Customers
    .OrderBy(x => x.PersonalInformation.LastName)
    .ThenBy(x => x.PersonalInformation.FirstName);
  var qs = query.ToQueryString();
  var customers = query.ToList();
  //if only one customer, nothing to test
  if (customers.Count <= 1) { return; }
  for (int x = 0; x < customers.Count - 1; x++)
  {
    var pi = customers[x].PersonalInformation;
    var pi2 = customers[x + 1].PersonalInformation;
    var compareLastName = string.Compare(pi.LastName,
        pi2.LastName, StringComparison.CurrentCultureIgnoreCase);
    Assert.True(compareLastName <= 0);
    if (compareLastName != 0) continue;
    var compareFirstName = string.Compare(pi.FirstName,
        pi2.FirstName, StringComparison.CurrentCultureIgnoreCase);
    Assert.True(compareFirstName <= 0);
  }
}

前面的 LINQ 查询被转换为以下内容:

SELECT [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]
ORDER BY [c].[LastName], [c].[FirstName]

反向排序记录

Reverse()方法颠倒了整个排序顺序,如下一个测试所示:

[Fact]
public void ShouldSortByFirstNameThenLastNameUsingReverse()
{
  //Sort by Last name then first name then reverse the sort
  var query = Context.Customers
    .OrderBy(x => x.PersonalInformation.LastName)
    .ThenBy(x => x.PersonalInformation.FirstName)
    .Reverse();
  var qs = query.ToQueryString();
  var customers = query.ToList();
  //if only one customer, nothing to test
  if (customers.Count <= 1) { return; }

  for (int x = 0; x < customers.Count - 1; x++)
  {
    var pi1 = customers[x].PersonalInformation;
    var pi2 = customers[x + 1].PersonalInformation;
    var compareLastName = string.Compare(pi1.LastName,
    pi2.LastName, StringComparison.CurrentCultureIgnoreCase);
    Assert.True(compareLastName >= 0);
    if (compareLastName != 0) continue;
    var compareFirstName = string.Compare(pi1.FirstName,
    pi2.FirstName, StringComparison.CurrentCultureIgnoreCase);
    Assert.True(compareFirstName >= 0);
  }
}

前面的 LINQ 查询被转换为以下内容:

SELECT [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]
ORDER BY [c].[LastName] DESC, [c].[FirstName] DESC

检索单个记录

查询返回单个记录主要有三种方法:First() / FirstOrDefault()Last() / LastOrDefault()Single() / SingleOrDefault()。虽然这三种方法都返回一条记录,但它们的方法都不同。下面详细介绍了这三种方法及其变体:

  • First()返回匹配查询条件和任何排序子句的第一条记录。如果没有指定顺序,则返回的记录基于数据库顺序。如果没有记录返回,将引发异常。

  • 除了如果没有记录匹配查询之外,FirstOrDefault()行为匹配First(),该方法返回类型的默认值(null)。

  • Single()返回匹配查询条件和任何排序子句的第一条记录。如果没有指定顺序,则返回的记录基于数据库顺序。如果没有记录或有多条记录与查询匹配,则会引发异常。

  • 除了如果没有记录匹配查询之外,SingleOrDefault()行为匹配Single(),该方法返回类型的默认值(null)。

  • Last()返回匹配查询条件和任何排序子句的最后一条记录。如果没有指定顺序,则会引发异常。如果没有记录返回,将引发异常。

  • 除了如果没有记录匹配查询之外,LastOrDefault()行为匹配Last(),该方法返回类型的默认值(null)。

所有方法还可以使用一个Expression<Func<T, bool>>(一个 lambda)来过滤结果集。这意味着您可以将Where()表达式放在对First() / Single()方法的调用中。以下语句是等效的:

Context.Customers.Where(c=>c.Id < 5).First();
Context.Customers.First(c=>c.Id < 5);

由于直接执行单记录 LINQ 语句,ToQueryString()方法不可用。列出的查询翻译是使用 SQL Server Profiler 提供的。

首先使用

当使用无参数形式的First()FirstOrDefault()时,将返回第一条记录(基于数据库顺序或任何前面的排序子句)。

以下测试根据数据库顺序获取第一条记录:

[Fact]
public void GetFirstMatchingRecordDatabaseOrder()
{
  //Gets the first record, database order
  var customer = Context.Customers.First();
  Assert.Equal(1, customer.Id);
}

前面的 LINQ 查询被转换为以下内容:

SELECT TOP(1) [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]

以下测试根据“姓,名”的顺序获取第一条记录:

[Fact]
public void GetFirstMatchingRecordNameOrder()
{
  //Gets the first record, lastname, first name order
  var customer = Context.Customers
      .OrderBy(x => x.PersonalInformation.LastName)
      .ThenBy(x => x.PersonalInformation.FirstName)
      .First();
  Assert.Equal(1, customer.Id);
}

前面的 LINQ 查询被转换为以下内容:

SELECT TOP(1) [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]
ORDER BY [c].[LastName], [c].[FirstName]

下面的测试断言,如果使用First()时没有匹配,就会抛出一个异常:

[Fact]
public void FirstShouldThrowExceptionIfNoneMatch()
{
  //Filters based on Id. Throws due to no match
  Assert.Throws<InvalidOperationException>(() => Context.Customers.First(x => x.Id == 10));
}

前面的 LINQ 查询被转换为以下内容:

SELECT TOP(1) [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]
WHERE [c].[Id] = 10

Note

Assert.Throws()是一种特殊类型的断言语句。它需要由表达式中的代码引发的异常。如果异常没有被抛出,断言失败。

当使用FirstOrDefault()时,当没有数据返回时,结果不是一个异常,而是一个空记录。

[Fact]
public void FirstOrDefaultShouldReturnDefaultIfNoneMatch()
{
  //Expression<Func<Customer>> is a lambda expression
  Expression<Func<Customer, bool>> expression = x => x.Id == 10;
  //Returns null when nothing is found
  var customer = Context.Customers.FirstOrDefault(expression);
  Assert.Null(customer);
}

前面的 LINQ 查询被翻译成与前面相同的形式:

SELECT TOP(1) [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]
WHERE [c].[Id] = 10

使用最后

当使用无参数形式的Last()LastOrDefault()时,将返回最后一条记录(基于任何前面的排序子句)。

以下测试根据“姓,名”的顺序获取最后一条记录:

[Fact]
public void GetLastMatchingRecordNameOrder()
{
  //Gets the last record, lastname desc, first name desc order
  var customer = Context.Customers
      .OrderBy(x => x.PersonalInformation.LastName)
      .ThenBy(x => x.PersonalInformation.FirstName)
      .Last();
  Assert.Equal(4, customer.Id);
}

EF 内核反转order by语句,然后取top(1)得到结果。以下是执行的查询:

SELECT TOP(1) [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]
ORDER BY [c].[LastName] DESC, [c].[FirstName] DESC

使用单

从概念上讲,Single() / SingleOrDefault()First() / FirstOrDefault().的工作原理相同,主要区别在于Single() / SingleOrDefault()返回Top(2)而不是Top(1),如果从数据库返回两条记录,则抛出异常。

以下测试检索单个记录,其中Id == 1:

[Fact]
public void GetOneMatchingRecordWithSingle()
{
  //Gets the first record, database order
  var customer = Context.Customers.Single(x => x.Id == 1);
  Assert.Equal(1, customer.Id);
}

前面的 LINQ 查询被转换为以下内容:

SELECT TOP(2) [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]
WHERE [c].[Id] = 1

如果没有记录返回,抛出异常。

[Fact]
public void SingleShouldThrowExceptionIfNoneMatch()
{
  //Filters based on Id. Throws due to no match
  Assert.Throws<InvalidOperationException>(() => Context.Customers.Single(x => x.Id == 10));
}

前面的 LINQ 查询被转换为以下内容:

SELECT TOP(2) [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]
WHERE [c].[Id] = 10

当使用Single()SingleOrDefault()并且返回多条记录时,会抛出异常。

[Fact]
public void SingleShouldThrowExceptionIfMoreThenOneMatch()
{
  // Throws due to more than one match
  Assert.Throws<InvalidOperationException>(() => Context.Customers.Single());
}
[Fact]
public void SingleOrDefaultShouldThrowExceptionIfMoreThenOneMatch()
{
  // Throws due to more than one match
  Assert.Throws<InvalidOperationException>(() => Context.Customers.SingleOrDefault());
}

前面的 LINQ 查询被转换为以下内容:

SELECT TOP(2) [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]

当使用SingleOrDefault()时,当没有数据返回时,结果不是一个异常,而是一个空记录。

[Fact]
public void SingleOrDefaultShouldReturnDefaultIfNoneMatch()
{
  //Expression<Func<Customer>> is a lambda expression
  Expression<Func<Customer, bool>> expression = x => x.Id == 10;
  //Returns null when nothing is found
  var customer = Context.Customers.SingleOrDefault(expression);
  Assert.Null(customer);
}

前面的 LINQ 查询被转换为以下内容:

SELECT TOP(1) [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]
WHERE [c].[Id] = 10

全局查询过滤器

回想一下,Car实体上有一个全局查询过滤器,用于过滤掉任何IsDrivable为假的汽车。

modelBuilder.Entity<Car>(entity =>
{
  entity.HasQueryFilter(c => c.IsDrivable);
...
});

打开CarTests.cs类并添加下面的测试(除非特别提到,否则下一节中的所有测试都在CarTests.cs类中):

[Fact]
public void ShouldReturnDrivableCarsWithQueryFilterSet()
{
  IQueryable<Car> query = Context.Cars;
  var qs = query.ToQueryString();
  var cars = query.ToList();
  Assert.NotEmpty(cars);
  Assert.Equal(9, cars.Count);
}

同样,回想一下,我们在数据初始化过程中创建了 10 辆汽车,其中一辆被设置为不可驾驶。执行查询时,将应用全局查询过滤器,并执行以下 SQL:

SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId], [i].[PetName], [i].[TimeStamp]
FROM [dbo].[Inventory] AS [i]
WHERE [i].[IsDrivable] = CAST(1 AS bit)

Note

当加载相关实体以及使用FromSqlRaw()FromSqlInterpolated()时,也会应用全局查询过滤器。这些将很快涵盖。

禁用查询过滤器

要禁用查询中实体的全局查询过滤器,请将IgnoreQueryFilters()方法添加到 LINQ 查询中。这将禁用查询中所有实体的所有过滤器。如果有多个实体具有全局查询过滤器,并且需要一些实体的过滤器,则必须将它们添加到 LINQ 语句的Where()方法中。

将下面的测试添加到CarTests.cs类,这将禁用查询过滤器并返回所有记录:

[Fact]
public void ShouldGetAllOfTheCars()
{
  IQueryable<Car> query = Context.Cars.IgnoreQueryFilters();
  var qs = query.ToQueryString();
  var cars = query.ToList();
  Assert.Equal(10, cars.Count);
}

正如所料,消除不可驾驶汽车的where子句不再出现在生成的 SQL 中。

SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId], [i].[PetName], [i].[TimeStamp]
FROM [dbo].[Inventory] AS [i]

导航属性上的查询过滤器

除了对Car实体的全局查询过滤器之外,我们还向Order实体的CarNavigation属性添加了一个查询过滤器。

modelBuilder.Entity<Order>().HasQueryFilter(e => e.CarNavigation!.IsDrivable);

要查看这一点,请将下面的测试添加到OrderTests.cs类中:

[Fact]
public void ShouldGetAllOrdersExceptFiltered()
{
    var query = Context.Orders.AsQueryable();
    var qs = query.ToQueryString();
    var orders = query.ToList();
    Assert.NotEmpty(orders);
    Assert.Equal(4,orders.Count);
}

下面列出了生成的 SQL:

SELECT [o].[Id], [o].[CarId], [o].[CustomerId], [o].[TimeStamp]
FROM [Dbo].[Orders] AS [o]
INNER JOIN (
    SELECT [i].[Id], [i].[IsDrivable]
    FROM [dbo].[Inventory] AS [i]
    WHERE [i].[IsDrivable] = CAST(1 AS bit)\r\n) AS [t] ON [o].[CarId] = [t].[Id]
WHERE [t].[IsDrivable] = CAST(1 AS bit)

因为CarNavigation导航属性是一个必需的导航属性,所以查询翻译引擎使用一个INNER JOIN,消除了Car不可驱动的Order记录。

要返回所有记录,将IgnoreQueryFilters()添加到您的 LINQ 查询中。

急切地加载相关数据

正如上一章所讨论的,通过导航属性链接的实体可以在一个查询中使用快速加载进行实例化。Include()方法表示对相关实体的连接,ThenInclude()方法用于后续的连接。这两种方法都将在这些测试中演示。如前所述,当Include() / ThenInclude()方法被翻译成 SQL 时,必需的关系使用内部连接,可选的关系使用左连接。

将以下测试添加到CarTests.cs类中,以显示单个Include():

[Fact]
public void ShouldGetAllOfTheCarsWithMakes()
{
  IIncludableQueryable<Car, Make?> query =
  Context.Cars.Include(c => c.MakeNavigation);
  var queryString = query.ToQueryString();
  var cars = query.ToList();
  Assert.Equal(9, cars.Count);
}

该测试将MakeNavigation属性添加到结果中,通过执行下面的 SQL 来执行内部连接。请注意,全局查询过滤器已经生效。

SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId], [i].[PetName], [i].[TimeStamp],
    [m].[Id], [m].[Name], [m].[TimeStamp]
FROM [dbo].[Inventory] AS [i]
INNER JOIN [dbo].[Makes] AS [m] ON [i].[MakeId] = [m].[Id]
WHERE [i].[IsDrivable] = CAST(1 AS bit)

第二个测试使用两组相关数据。第一个是获取Make信息(与前面的测试相同),而第二个是获取Orders,然后是附加到Orders.Customers,整个测试还过滤掉了有任何订单的Car记录。可选关系生成左连接。

[Fact]
public void ShouldGetCarsOnOrderWithRelatedProperties()
{
  IIncludableQueryable<Car, Customer?> query = Context.Cars
    .Where(c => c.Orders.Any())
    .Include(c => c.MakeNavigation)
    .Include(c => c.Orders).ThenInclude(o => o.CustomerNavigation);
  var queryString = query.ToQueryString();
  var cars = query.ToList();
  Assert.Equal(4, cars.Count);
  cars.ForEach(c =>
  {
    Assert.NotNull(c.MakeNavigation);
    Assert.NotNull(c.Orders.ToList()[0].CustomerNavigation);
  });
}

下面是生成的查询:

SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId], [i].[PetName], [i].[TimeStamp],
    [m].[Id], [m].[Name], [m].[TimeStamp], [t0].[Id], [t0].[CarId], [t0].[CustomerId],
    [t0].[TimeStamp], [t0].[Id0], [t0].[TimeStamp0], [t0].[FirstName], [t0].[FullName],
    [t0].[LastName], [t0].[Id1]
FROM [dbo].[Inventory] AS [i]
     INNER JOIN [dbo].[Makes] AS [m] ON [i].[MakeId]=[m].[Id]
     LEFT JOIN(SELECT [o].[Id], [o].[CarId], [o].[CustomerId], [o].[TimeStamp],
        [c].[Id] AS [Id0], [c].[TimeStamp] AS [TimeStamp0], [c].[FirstName], [c].[FullName],
        [c].[LastName], [t].[Id] AS [Id1]
               FROM [dbo].[Orders] AS [o]
                    INNER JOIN(SELECT [i0].[Id], [i0].[IsDrivable]
                               FROM [dbo].[Inventory] AS [i0]
                               WHERE [i0].[IsDrivable]=CAST(1 AS BIT)) AS [t] ON [o].[CarId]=[t].[Id]
                    INNER JOIN [dbo].[Customers] AS [c] ON [o].[CustomerId]=[c].[Id]
               WHERE [t].[IsDrivable]=CAST(1 AS BIT)) AS [t0] ON [i].[Id]=[t0].[CarId]
WHERE([i].[IsDrivable]=CAST(1 AS BIT))AND EXISTS (SELECT 1
                                                  FROM [dbo].[Orders] AS [o0]
                                                       INNER JOIN(SELECT [i1].[Id], [i1].[Color], [i1].[IsDrivable],
                                                                                  [i1].[MakeId], [i1].[PetName], [i1].[TimeStamp]
                                                                  FROM [dbo].[Inventory] AS [i1]
                                                                  WHERE [i1].[IsDrivable]=CAST(1 AS BIT)) AS [t1] ON [o0].[CarId]=[t1].[Id]
                                                  WHERE([t1].[IsDrivable]=CAST(1 AS BIT))AND([i].[Id]=[o0].[CarId]))
ORDER BY [i].[Id], [m].[Id], [t0].[Id], [t0].[Id1], [t0].[Id0];

拆分对相关数据的查询

LINQ 查询中添加的连接越多,生成的查询就越复杂。EF Core 5 的新特性是能够将复杂的连接作为分割查询运行。参考前一章的完整讨论,但是总结一下,将AsSplitQuery()方法添加到 LINQ 查询中指示 EF Core 将对数据库的调用分成多个调用。这可以在数据不一致的风险下提高效率。将以下测试添加到您的测试夹具中:

[Fact]
public void ShouldGetCarsOnOrderWithRelatedPropertiesAsSplitQuery()
{
  IQueryable<Car> query = Context.Cars.Where(c => c.Orders.Any())
    .Include(c => c.MakeNavigation)
    .Include(c => c.Orders).ThenInclude(o => o.CustomerNavigation)
    .AsSplitQuery();
  var cars = query.ToList();
  Assert.Equal(4, cars.Count);
  cars.ForEach(c =>
  {
    Assert.NotNull(c.MakeNavigation);
    Assert.NotNull(c.Orders.ToList()[0].CustomerNavigation);
  });
}

ToQueryString()方法只返回第一个查询,因此下面的查询是使用 SQL Server Profiler 捕获的:

SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId], [i].[PetName], [i].[TimeStamp], [m].[Id], [m].[Name], [m].[TimeStamp]
FROM [dbo].[Inventory] AS [i]
INNER JOIN [dbo].[Makes] AS [m] ON [i].[MakeId] = [m].[Id]
WHERE ([i].[IsDrivable] = CAST(1 AS bit)) AND EXISTS (
    SELECT 1
    FROM [Dbo].[Orders] AS [o]
    INNER JOIN (
        SELECT [i0].[Id], [i0].[Color], [i0].[IsDrivable], [i0].[MakeId], [i0].[PetName], [i0].[TimeStamp]
        FROM [dbo].[Inventory] AS [i0]
        WHERE [i0].[IsDrivable] = CAST(1 AS bit)
    ) AS [t] ON [o].[CarId] = [t].[Id]
    WHERE ([t].[IsDrivable] = CAST(1 AS bit)) AND ([i].[Id] = [o].[CarId]))
ORDER BY [i].[Id], [m].[Id]

SELECT [t0].[Id], [t0].[CarId], [t0].[CustomerId], [t0].[TimeStamp], [t0].[Id1], [t0].[TimeStamp1], [t0].[FirstName], [t0].[FullName], [t0].[LastName], [i].[Id], [m].[Id]
FROM [dbo].[Inventory] AS [i]
INNER JOIN [dbo].[Makes] AS [m] ON [i].[MakeId] = [m].[Id]
INNER JOIN (
    SELECT [o].[Id], [o].[CarId], [o].[CustomerId], [o].[TimeStamp], [c].[Id] AS [Id1], [c].[TimeStamp] AS [TimeStamp1], [c].[FirstName], [c].[FullName], [c].[LastName]
    FROM [Dbo].[Orders] AS [o]
    INNER JOIN (
        SELECT [i0].[Id], [i0].[IsDrivable]
        FROM [dbo].[Inventory] AS [i0]
        WHERE [i0].[IsDrivable] = CAST(1 AS bit)
    ) AS [t] ON [o].[CarId] = [t].[Id]
    INNER JOIN [Dbo].[Customers] AS [c] ON [o].[CustomerId] = [c].[Id]
    WHERE [t].[IsDrivable] = CAST(1 AS bit)
) AS [t0] ON [i].[Id] = [t0].[CarId]
WHERE ([i].[IsDrivable] = CAST(1 AS bit)) AND EXISTS (
    SELECT 1
    FROM [Dbo].[Orders] AS [o0]
    INNER JOIN (
        SELECT [i1].[Id], [i1].[Color], [i1].[IsDrivable], [i1].[MakeId], [i1].[PetName], [i1].[TimeStamp]
        FROM [dbo].[Inventory] AS [i1]
        WHERE [i1].[IsDrivable] = CAST(1 AS bit)
    ) AS [t1] ON [o0].[CarId] = [t1].[Id]
    WHERE ([t1].[IsDrivable] = CAST(1 AS bit)) AND ([i].[Id] = [o0].[CarId]))
ORDER BY [i].[Id], [m].[Id]

是否拆分查询取决于您的业务需求。

过滤相关数据

EF Core 5 在包含集合属性时引入了过滤功能。在 EF Core 5 之前,获取集合导航属性的过滤列表的唯一方法是使用显式加载。将下面的测试添加到MakeTests.cs类中,它演示了如何获取所有的Make记录,这些汽车都是黄色的:

[Fact]
public void ShouldGetAllMakesAndCarsThatAreYellow()
{
  var query = Context.Makes.IgnoreQueryFilters()
      .Include(x => x.Cars.Where(x => x.Color == "Yellow"));
  var qs = query.ToQueryString();
  var makes = query.ToList();
  Assert.NotNull(makes);
  Assert.NotEmpty(makes);
  Assert.NotEmpty(makes.Where(x => x.Cars.Any()));
  Assert.Empty(makes.First(m => m.Id == 1).Cars);
  Assert.Empty(makes.First(m => m.Id == 2).Cars);
  Assert.Empty(makes.First(m => m.Id == 3).Cars);
  Assert.Single(makes.First(m => m.Id == 4).Cars);
  Assert.Empty(makes.First(m => m.Id == 5).Cars);
}

生成的 SQL 如下所示:

SELECT [m].[Id], [m].[Name], [m].[TimeStamp], [t].[Id], [t].[Color], [t].[IsDrivable],
  [t].[MakeId], [t].[PetName], [t].[TimeStamp]
FROM [dbo].[Makes] AS [m]
LEFT JOIN (
     SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId], [i].[PetName], [i].[TimeStamp]
     FROM [dbo].[Inventory] AS [i]
     WHERE [i].[Color] = N'Yellow') AS [t] ON [m].[Id] = [t].[MakeId]
ORDER BY [m].[Id], [t].[Id]

将查询更改为拆分查询会生成以下 SQL(来自 SQL Server Profiler 的集合):

SELECT [m].[Id], [m].[Name], [m].[TimeStamp]
FROM [dbo].[Makes] AS [m]
ORDER BY [m].[Id]

SELECT [t].[Id], [t].[Color], [t].[IsDrivable], [t].[MakeId], [t].[PetName], [t].[TimeStamp], [m].[Id]
FROM [dbo].[Makes] AS [m]
INNER JOIN (
    SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId], [i].[PetName], [i].[TimeStamp]
    FROM [dbo].[Inventory] AS [i]
    WHERE [i].[Color] = N'Yellow'
) AS [t] ON [m].[Id] = [t].[MakeId]
ORDER BY [m].[Id]

显式加载相关数据

如果需要在将主体实体查询到内存中之后加载相关数据,可以通过后续的数据库调用从数据库中检索相关实体。这是使用派生的DbContext上的Entry()方法触发的。当在一对多关系的多端加载实体时,对Entry结果使用Collection()方法。要加载一对多(或一对一关系)一端的实体,使用Reference()方法。在Collection()Reference()方法上调用Query()会返回一个IQueryable<T>,它可用于获取查询字符串(如下面的测试所示)和管理查询过滤器(如下一节所示)。要执行查询并加载记录,请在Collection()Reference()Query()方法上调用Load()方法。当调用Load()时,查询立即执行。

下面的测试(回到CarTests.cs类)展示了如何在Car实体上加载一个引用导航属性:

[Fact]
public void ShouldGetReferenceRelatedInformationExplicitly()
{
  var car = Context.Cars.First(x => x.Id == 1);
  Assert.Null(car.MakeNavigation);
  var query = Context.Entry(car).Reference(c => c.MakeNavigation).Query();
  var qs = query.ToQueryString();
  query.Load();
  Assert.NotNull(car.MakeNavigation);
}

生成的 SQL 如下所示:

DECLARE @__p_0 int = 1;
SELECT [m].[Id], [m].[Name], [m].[TimeStamp]
FROM [dbo].[Makes] AS [m]
WHERE [m].[Id] = @__p_0

该测试显示了如何在Car实体上加载集合导航属性:

[Fact]
public void ShouldGetCollectionRelatedInformationExplicitly()
{
  var car = Context.Cars.First(x => x.Id == 1);
  Assert.Empty(car.Orders);
  var query = Context.Entry(car).Collection(c => c.Orders).Query();
  var qs = query.ToQueryString();
  query.Load();
  Assert.Single(car.Orders);
}

生成的 SQL 如下所示:

DECLARE @__p_0 int = 1;
SELECT [o].[Id], [o].[CarId], [o].[CustomerId], [o].[TimeStamp]
FROM [Dbo].[Orders] AS [o]
INNER JOIN (
    SELECT [i].[Id], [i].[IsDrivable]
    FROM [dbo].[Inventory] AS [i]
    WHERE [i].[IsDrivable] = CAST(1 AS bit)
) AS [t] ON [o].[CarId] = [t].[Id]
WHERE ([t].[IsDrivable] = CAST(1 AS bit)) AND ([o].[CarId] = @__p_0)

使用查询过滤器显式加载相关数据

除了调整急切加载相关数据时生成的查询之外,全局查询过滤器在显式加载相关数据时是活动的。参加以下测试(在MakeTests.cs类中):

[Theory]
[InlineData(1,1)]
[InlineData(2,1)]
[InlineData(3,1)]
[InlineData(4,2)]
[InlineData(5,3)]
[InlineData(6,1)]
public void ShouldGetAllCarsForAMakeExplicitlyWithQueryFilters(int makeId, int carCount)
{
  var make = Context.Makes.First(x => x.Id == makeId);
  IQueryable<Car> query = Context.Entry(make).Collection(c => c.Cars).Query();
  var qs = query.ToQueryString();
  query.Load();
  Assert.Equal(carCount,make.Cars.Count());
}

该测试类似于“过滤记录”部分的ShouldGetTheCarsByMake()。然而,测试不是只获取具有某个MakeIdCar记录,而是首先获取一个Make记录,然后显式地为内存中的Make记录加载Car记录。生成的查询如下所示:

DECLARE @__p_0 int = 5;
SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId], [i].[PetName], [i].[TimeStamp]
FROM [dbo].[Inventory] AS [i]
WHERE ([i].[IsDrivable] = CAST(1 AS bit)) AND ([i].[MakeId] = @__p_0)

请注意,查询过滤器仍在使用,尽管查询中的主体实体是Make记录。要在显式加载记录时关闭查询过滤器,请结合使用IgnoreQueryFilters()Query()方法。下面是关闭查询过滤器的测试(还是在MakeTests.cs类中):

[Theory]
[InlineData(1, 2)]
[InlineData(2, 1)]
[InlineData(3, 1)]
[InlineData(4, 2)]
[InlineData(5, 3)]
[InlineData(6, 1)]
public void ShouldGetAllCarsForAMakeExplicitly(int makeId, int carCount)
{
  var make = Context.Makes.First(x => x.Id == makeId);
  IQueryable<Car> query =
    Context.Entry(make).Collection(c => c.Cars).Query().IgnoreQueryFilters();
  var qs = query.IgnoreQueryFilters().ToQueryString();
  query.Load();
  Assert.Equal(carCount, make.Cars.Count());
}

使用 LINQ 的 SQL 查询

如果特定查询的 LINQ 语句过于复杂,或者测试显示性能低于预期,可以使用原始 SQL 语句,使用DbSet<T>FromSqlRaw()FromSqlInterpolated()方法来检索数据。SQL 语句可以是内联 T-SQL select 语句、存储过程或表值函数。如果查询是开放查询(例如,没有终止分号的 T-SQL 语句),那么可以将 LINQ 语句添加到FromSqlRaw() / FromSqlInterpolated()方法中,以进一步定义生成的查询。整个查询在服务器端执行,将 SQL 语句与从 LINQ 语句生成的 SQL 结合起来。

如果语句被终止或包含无法构建的 SQL(例如,使用公共表表达式),该查询仍在服务器端执行,但任何附加的过滤和处理必须在客户端作为对象的 LINQ 来完成。

FromSqlRaw()完全按照输入的内容执行查询。FromSqlInterpolated()使用 C# 字符串插值,然后将插值转换为参数。下面的测试(在CarTests.cs类中)展示了使用这两种方法的例子,有和没有全局查询过滤器:

[Fact]
public void ShouldNotGetTheLemonsUsingFromSql()
{
    var entity = Context.Model.FindEntityType($"{typeof(Car).FullName}");
    var tableName = entity.GetTableName();
    var schemaName = entity.GetSchema();
    var cars = Context.Cars.FromSqlRaw($"Select * from {schemaName}.{tableName}").ToList();
    Assert.Equal(9, cars.Count);
}

[Fact]
public void ShouldGetTheCarsUsingFromSqlWithIgnoreQueryFilters()
{
    var entity = Context.Model.FindEntityType($"{typeof(Car).FullName}");
    var tableName = entity.GetTableName();
    var schemaName = entity.GetSchema();
    var cars = Context.Cars.FromSqlRaw($"Select * from {schemaName}.{tableName}")
        .IgnoreQueryFilters().ToList();
    Assert.Equal(10, cars.Count);
}

[Fact]
public void ShouldGetOneCarUsingInterpolation()
{
    var carId = 1;
    var car = Context.Cars
        .FromSqlInterpolated($"Select * from dbo.Inventory where Id = {carId}")
        .Include(x => x.MakeNavigation)
        .First();
    Assert.Equal("Black", car.Color);
    Assert.Equal("VW", car.MakeNavigation.Name);
}

[Theory]
[InlineData(1, 1)]
[InlineData(2, 1)]
[InlineData(3, 1)]
[InlineData(4, 2)]
[InlineData(5, 3)]
[InlineData(6, 1)]
public void ShouldGetTheCarsByMakeUsingFromSql(int makeId, int expectedCount)
{
  var entity = Context.Model.FindEntityType($"{typeof(Car).FullName}");
  var tableName = entity.GetTableName();
  var schemaName = entity.GetSchema();
  var cars = Context.Cars.FromSqlRaw($"Select * from {schemaName}.{tableName}")
    .Where(x => x.MakeId == makeId).ToList();
  Assert.Equal(expectedCount, cars.Count);
}

使用FromSqlRaw() / FromSqlInterpolated()方法时有一些规则:SQL 语句返回的列必须与模型上映射的列相匹配,必须返回模型的所有列,不能返回相关数据。

聚合方法

EF Core 还支持服务器端聚合方法(Max()Min()Count()Average(),等)。).可以使用Where()方法将聚合方法添加到 LINQ 查询的末尾,或者过滤表达式可以包含在聚合方法本身中(就像First()Single())。聚合在服务器端执行,并且从查询中返回单个值。全局查询过滤器也影响聚合方法,可以用IgnoreQueryFilters()禁用。

本节中显示的所有 SQL 语句都是使用 SQL Server Profiler 收集的。

第一个测试(在CarTests.cs)中)只是统计数据库中所有的Car记录。由于查询过滤器仍然处于活动状态,因此计数返回 9 辆汽车。

[Fact]
public void ShouldGetTheCountOfCars()
{
  var count = Context.Cars.Count();
  Assert.Equal(9, count);
}

执行的 SQL 如下所示:

SELECT COUNT(*)
FROM [dbo].[Inventory] AS [i]
WHERE [i].[IsDrivable] = CAST(1 AS bit)

通过添加IgnoreQueryFilters(),Count()方法返回 10,并且从 SQL 查询中删除了where子句。

[Fact]
public void ShouldGetTheCountOfCarsIgnoreQueryFilters()
{
  var count = Context.Cars.IgnoreQueryFilters().Count();
  Assert.Equal(10, count);
}

--Generated SQL
SELECT COUNT(*) FROM [dbo].[Inventory] AS [i]

以下测试(也在CarTests.cs中)演示了带有where条件的Count()方法。第一个测试将表达式直接添加到Count()方法中,第二个测试将Count()方法添加到 LINQ 语句的末尾。

[Theory]
[InlineData(1, 1)]
[InlineData(2, 1)]
[InlineData(3, 1)]
[InlineData(4, 2)]
[InlineData(5, 3)]
[InlineData(6, 1)]
public void ShouldGetTheCountOfCarsByMakeP1(int makeId, int expectedCount)
{
    var count = Context.Cars.Count(x=>x.MakeId == makeId);
    Assert.Equal(expectedCount, count);
}

[Theory]
[InlineData(1, 1)]
[InlineData(2, 1)]
[InlineData(3, 1)]
[InlineData(4, 2)]
[InlineData(5, 3)]
[InlineData(6, 1)]
public void ShouldGetTheCountOfCarsByMakeP2(int makeId, int expectedCount)
{
    var count = Context.Cars.Where(x => x.MakeId == makeId).Count();
    Assert.Equal(expectedCount, count);
}

两个测试都创建了相同的对服务器的 SQL 调用,如下所示(MakeId随着基于InlineData的每个测试而变化):

exec sp_executesql N'SELECT COUNT(*)
FROM [dbo].[Inventory] AS [i]
WHERE ([i].[IsDrivable] = CAST(1 AS bit)) AND ([i].[MakeId] = @__makeId_0)'
,N'@__makeId_0 int',@__makeId_0=6

任意()和全部()

Any()All()方法检查一组记录,查看是否有任何记录符合标准(Any())或者是否所有记录都符合标准(All())。就像聚合方法一样,它们可以用Where()方法添加到 LINQ 查询的末尾,或者过滤表达式可以包含在方法本身中。Any()All()方法在服务器端执行,查询返回一个布尔值。全局查询过滤器也影响Any()All()方法函数,并且可以用IgnoreQueryFilters()禁用。

本节中显示的所有 SQL 语句都是使用 SQL Server Profiler 收集的。

第一个测试(在CarTests.cs)中)检查的任何汽车记录是否有特定的MakeId

[Theory]
[InlineData(1, true)]
[InlineData(11, false)]
public void ShouldCheckForAnyCarsWithMake(int makeId, bool expectedResult)
{
  var result = Context.Cars.Any(x => x.MakeId == makeId);
  Assert.Equal(expectedResult, result);
}

第一次理论测试执行的 SQL 如下所示:

exec sp_executesql N'SELECT CASE
    WHEN EXISTS (
        SELECT 1
        FROM [dbo].[Inventory] AS [i]
        WHERE ([i].[IsDrivable] = CAST(1 AS bit)) AND ([i].[MakeId] = @__makeId_0)) THEN CAST(1 AS bit)
    ELSE CAST(0 AS bit)
END',N'@__makeId_0 int',@__makeId_0=1

第二个测试检查是否所有的汽车记录都有一个特定的MakeId

[Theory]
[InlineData(1, false)]
[InlineData(11, false)]
public void ShouldCheckForAllCarsWithMake(int makeId, bool expectedResult)
{
  var result = Context.Cars.All(x => x.MakeId == makeId);
  Assert.Equal(expectedResult, result);
}

第一次理论测试执行的 SQL 如下所示:

exec sp_executesql N'SELECT CASE
    WHEN NOT EXISTS (
        SELECT 1
        FROM [dbo].[Inventory] AS [i]
        WHERE ([i].[IsDrivable] = CAST(1 AS bit)) AND ([i].[MakeId] <> @__makeId_0)) THEN CAST(1 AS bit)
    ELSE CAST(0 AS bit)
END',N'@__makeId_0 int',@__makeId_0=1

从存储过程中获取数据

要检查的最后一个数据检索模式是从存储过程中获取数据。虽然 EF Core 在存储过程方面存在一些差距(与 EF 6 相比),但请记住 EF Core 是建立在 ADO.NET 之上的。我们只需要放下一层,记住我们是如何调用存储过程的。CarRepo中的以下方法创建所需的参数(输入和输出),利用ApplicationDbContext Database属性,并调用ExecuteSqlRaw():

public string GetPetName(int id)
{
  var parameterId = new SqlParameter
  {
    ParameterName = "@carId",
    SqlDbType = System.Data.SqlDbType.Int,
    Value = id,
  };

  var parameterName = new SqlParameter
  {
    ParameterName = "@petName",
    SqlDbType = System.Data.SqlDbType.NVarChar,
    Size = 50,
    Direction = ParameterDirection.Output
  };

  var result = Context.Database
      .ExecuteSqlRaw("EXEC [dbo].[GetPetName] @carId, @petName OUTPUT",parameterId, parameterName);
  return (string)parameterName.Value;
}

有了这些代码,测试就变得简单了。将以下测试添加到CarTests.cs类中:

[Theory]
[InlineData(1, "Zippy")]
[InlineData(2, "Rusty")]
[InlineData(3, "Mel")]
[InlineData(4, "Clunker")]
[InlineData(5, "Bimmer")]
[InlineData(6, "Hank")]
[InlineData(7, "Pinky")]
[InlineData(8, "Pete")]
[InlineData(9, "Brownie")]
public void ShouldGetValueFromStoredProc(int id, string expectedName)
{
    Assert.Equal(expectedName, new CarRepo(Context).GetPetName(id));
}

创建记录

通过在代码中创建记录,将它们添加到它们的DbSet<T>,并在上下文中调用SaveChanges() / SaveChangesAsync()来将记录添加到数据库中。当执行SaveChanges()时,ChangeTracker报告所有添加的实体,EF Core(和数据库提供者一起)创建适当的 SQL 语句来插入记录。

提醒一下,SaveChanges()在隐式事务中执行,除非使用显式事务。如果保存成功,则查询服务器生成的值来设置实体的值。这些测试都将使用一个显式事务,因此可以回滚更改,使数据库保持测试开始时的状态。

本节中显示的所有 SQL 语句都是使用 SQL Server Profiler 收集的。

Note

也可以使用派生的DbContext添加记录。这些例子都将使用DbSet<T>集合属性来添加记录。DbSet<T>DbContext都有异步版本的Add() / AddRange()。仅显示同步版本。

实体状态

当一个实体通过代码创建,但还没有添加到一个DbSet<T>中时,EntityState就是Detached。一旦一个新的实体被添加到DbSet<T>,则EntityState被设置为Added。在SaveChanges()执行成功后,EntityState被设置为Unchanged

添加一条记录

以下测试演示了如何向Inventory表添加一条记录:

[Fact]
public void ShouldAddACar()
{
  ExecuteInATransaction(RunTheTest);

  void RunTheTest()
  {
    var car = new Car
    {
      Color = "Yellow",
      MakeId = 1,
      PetName = "Herbie"
    };
    var carCount = Context.Cars.Count();
    Context.Cars.Add(car);
    Context.SaveChanges();
    var newCarCount = Context.Cars.Count();
    Assert.Equal(carCount+1,newCarCount);
  }
}

这里显示了执行的 SQL 语句。注意,最近添加的实体被查询数据库生成的属性(IdTimeStamp))。当查询结果到达 EF 核心时,实体用服务器端的值更新。

exec sp_executesql N'SET NOCOUNT ON;
INSERT INTO [dbo].[Inventory] ([Color], [MakeId], [PetName])
VALUES (@p0, @p1, @p2);

SELECT [Id], [IsDrivable], [TimeStamp]
FROM [dbo].[Inventory]
WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();

',N'@p0 nvarchar(50),@p1 int,@p2 nvarchar(50)',@p0=N'Yellow',@p1=1,@p2=N'Herbie'

使用附加添加单个记录

当实体的主键映射到 SQL Server 中的标识列时,如果主键属性值为零,EF Core 会将该实体实例视为Added。下面的测试创建了一个新的Car实体,其Id保留默认值零。当实体附加到ChangeTracker时,状态被设置为Added,调用SaveChanges()将实体添加到数据库中。

[Fact]
public void ShouldAddACarWithAttach()
{
  ExecuteInATransaction(RunTheTest);

  void RunTheTest()
  {
    var car = new Car
    {
      Color = "Yellow",
      MakeId = 1,
      PetName = "Herbie"
    };
    var carCount = Context.Cars.Count();
    Context.Cars.Attach(car);
    Assert.Equal(EntityState.Added, Context.Entry(car).State);
    Context.SaveChanges();
    var newCarCount = Context.Cars.Count();
    Assert.Equal(carCount + 1, newCarCount);
  }
}

一次添加多条记录

要在单个事务中插入多条记录,请使用DbSet<T>AddRange()方法,如本测试所示(注意,对于 SQL Server,为了在持久化数据时使用批处理,必须至少执行四个操作):

[Fact]
public void ShouldAddMultipleCars()
{
  ExecuteInATransaction(RunTheTest);

  void RunTheTest()
  {
    //Have to add 4 to activate batching
    var cars = new List<Car>
    {
      new() { Color = "Yellow", MakeId = 1, PetName = "Herbie" },
      new() { Color = "White", MakeId = 2, PetName = "Mach 5" },
      new() { Color = "Pink", MakeId = 3, PetName = "Avon" },
      new() { Color = "Blue", MakeId = 4, PetName = "Blueberry" },
    };
    var carCount = Context.Cars.Count();
    Context.Cars.AddRange(cars);
    Context.SaveChanges();
    var newCarCount = Context.Cars.Count();
    Assert.Equal(carCount + 4, newCarCount);
  }
}

add语句被批处理成对数据库的单个调用,所有生成的列都被查询。当查询结果到达 EF 核心时,实体用服务器端的值更新。执行的 SQL 语句如下所示:

exec sp_executesql N'SET NOCOUNT ON;
DECLARE @inserted0 TABLE ([Id] int, [_Position] [int]);
MERGE [dbo].[Inventory] USING (
VALUES (@p0, @p1, @p2, 0),
(@p3, @p4, @p5, 1),
(@p6, @p7, @p8, 2),
(@p9, @p10, @p11, 3)) AS i ([Color], [MakeId], [PetName], _Position) ON 1=0
WHEN NOT MATCHED THEN
INSERT ([Color], [MakeId], [PetName])
VALUES (i.[Color], i.[MakeId], i.[PetName])
OUTPUT INSERTED.[Id], i._Position
INTO @inserted0;

SELECT [t].[Id], [t].[IsDrivable], [t].[TimeStamp] FROM [dbo].[Inventory] t
INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id])
ORDER BY [i].[_Position];',
N'@p0 nvarchar(50),@p1 int,@p2 nvarchar(50),@p3 nvarchar(50),@p4 int,@p5 nvarchar(50),@p6 nvarchar(50),@p7 int,@p8 nvarchar(50),@p9 nvarchar(50),@p10 int,@p11 nvarchar(50)',@p0=N'Yellow',@p1=1,@p2=N'Herbie',@p3=N'White',@p4=2,@p5=N'Mach 5',@p6=N'Pink',@p7=3,@p8=N'Avon',@p9=N'Blue',@p10=4,@p11=N'Blueberry'

添加记录时的标识列注意事项

当实体具有定义为主键的数值属性时,该属性(默认情况下)会映射到 SQL Server 中的标识列。EF Core 将任何具有 key 属性默认值(零)的实体视为新实体,而将任何具有非默认值的实体视为数据库中已存在的实体。如果您创建一个新实体,并将主键属性设置为非零值,并尝试将其添加到数据库中,EF Core 将无法添加记录,因为身份插入未启用。Initialize数据代码演示了如何启用身份插入。

添加对象图

当向数据库添加实体时,只要将子记录添加到父记录的集合属性中,就可以在同一个调用中添加子记录,而无需专门将它们添加到它们自己的DbSet<T>中。例如,创建了一个新的Make实体,并且在MakeCars属性中添加了一个子Car记录。当Make实体被添加到DbSet<Make>属性中时,EF Core 也自动开始跟踪子Car记录,而不必将其显式添加到DbSet<Car>属性中。执行SaveChanges()MakeCar一起保存。以下测试演示了这一点:

[Fact]
public void ShouldAddAnObjectGraph()
{
  ExecuteInATransaction(RunTheTest);

  void RunTheTest()
  {
    var make = new Make {Name = "Honda"};
    var car = new Car { Color = "Yellow", MakeId = 1, PetName = "Herbie" };
    //Cast the Cars property to List<Car> from IEnumerable<Car>
    ((List<Car>)make.Cars).Add(car);
    Context.Makes.Add(make);
    var carCount = Context.Cars.Count();
    var makeCount = Context.Makes.Count();
    Context.SaveChanges();
    var newCarCount = Context.Cars. Count();
    var newMakeCount = Context.Makes. Count();
    Assert.Equal(carCount+1,newCarCount);
    Assert.Equal(makeCount+1,newMakeCount);
  }
}

add 语句不进行批处理,因为语句少于两个,而对于 SQL Server,批处理从四个语句开始。执行的 SQL 语句如下所示:

exec sp_executesql N'SET NOCOUNT ON;
INSERT INTO [dbo].[Makes] ([Name])
VALUES (@p0);
SELECT [Id], [TimeStamp]
FROM [dbo].[Makes]
WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();

',N'@p0 nvarchar(50)',@p0=N'Honda'

exec sp_executesql N'SET NOCOUNT ON;
INSERT INTO [dbo].[Inventory] ([Color], [MakeId], [PetName])
VALUES (@p1, @p2, @p3);
SELECT [Id], [IsDrivable], [TimeStamp]
FROM [dbo].[Inventory]
WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();

',N'@p1 nvarchar(50),@p2 int,@p3 nvarchar(50)',@p1=N'Yellow',@p2=7,@p3=N'Herbie'

更新记录

通过将记录作为被跟踪的实体加载到DbSet<T>中,通过代码更改它们,然后在上下文中调用SaveChanges()来更新记录。当执行SaveChanges()时,ChangeTracker报告所有修改的实体,EF Core(和数据库提供者一起)创建适当的 SQL 语句来更新记录。

实体状态

编辑被跟踪的实体时,EntityState被设置为Modified。更改成功保存后,状态返回Unchanged

更新跟踪的实体

更新单个记录非常类似于添加单个记录。将数据库中的记录加载到被跟踪的实体中,进行一些更改,然后调用SaveChanges()。注意,您不必在DbSet<T>上调用Update() / UpdateRange()方法,因为实体已经被跟踪了。下面的测试只更新一条记录,但是如果更新并保存多个被跟踪的实体,过程是相同的。

[Fact]
public void ShouldUpdateACar()
{
  ExecuteInASharedTransaction(RunTheTest);

  void RunTheTest(IDbContextTransaction trans)
  {
    var car = Context.Cars.First(c => c.Id == 1);
    Assert.Equal("Black",car.Color);
    car.Color = "White";
    //Calling update is not needed because the entity is tracked
    //Context.Cars.Update(car);
    Context.SaveChanges();
    Assert.Equal("White", car.Color);
    var context2 = TestHelpers.GetSecondContext(Context, trans);
    var car2 = context2.Cars.First(c => c.Id == 1);
    Assert.Equal("White", car2.Color);
  }
}

前面的代码使用了跨越两个ApplicationDbContext实例的共享事务。这是为了在执行测试的上下文和检查测试结果的上下文之间提供隔离。

执行的 SQL 语句如下所示:

exec sp_executesql N'SET NOCOUNT ON;
UPDATE [dbo].[Inventory] SET [Color] = @p0
WHERE [Id] = @p1 AND [TimeStamp] = @p2;
SELECT [TimeStamp]
FROM [dbo].[Inventory]
WHERE @@ROWCOUNT = 1 AND [Id] = @p1;

',N'@p1 int,@p0 nvarchar(50),@p2 varbinary(8)',@p1=1,@p0=N'White',@p2=0x000000000000862D

Note

前面的where子句不仅检查了Id列,还检查了TimeStamp列。并发检查,稍后将会介绍。

更新未跟踪的实体

未跟踪的实体也可以用于更新数据库记录。该过程类似于更新被跟踪的实体,除了该实体是在代码中创建的(并且不被查询),并且 EF 核心必须被通知该实体应该已经存在于数据库中并且需要被更新。

创建实体的实例后,有两种方法通知 EF Core 该实体需要作为更新进行处理。第一个是调用DbSet<T>上的Update()方法,它将状态设置为Modified,,如下所示:

context2.Cars.Update(updatedCar);

第二种是使用上下文实例和Entry()方法将状态设置为Modified,就像这样:

context2.Entry(updatedCar).State = EntityState.Modified;

无论哪种方式,都必须调用SaveChanges()来保持这些值。

下面的示例读取一个未跟踪的记录,从该记录创建一个新的Car类实例,并更改一个属性(Color)。然后,它要么设置状态,要么在DbSet<T>上使用Update()方法,这取决于您取消注释的代码行。Update()方法也将状态更改为Modified。测试然后调用SaveChanges()。所有额外的上下文都是为了确保测试的准确性,并且上下文之间没有任何交叉。

[Fact]
public void ShouldUpdateACarUsingState()
{
  ExecuteInASharedTransaction(RunTheTest);

  void RunTheTest(IDbContextTransaction trans)
  {
    var car = Context.Cars.AsNoTracking().First(c => c.Id == 1);
    Assert.Equal("Black", car.Color);
    var updatedCar = new Car
    {
      Color = "White", //Original is Black
      Id = car.Id,
      MakeId = car.MakeId,
      PetName = car.PetName,
      TimeStamp = car.TimeStamp
      IsDrivable = car.IsDrivable
    };
    var context2 = TestHelpers.GetSecondContext(Context, trans);
    //Either call Update or modify the state
    context2.Entry(updatedCar).State = EntityState.Modified;
    //context2.Cars.Update(updatedCar);
    context2.SaveChanges();
    var context3 =
      TestHelpers.GetSecondContext(Context, trans);
    var car2 = context3.Cars.First(c => c.Id == 1);
    Assert.Equal("White", car2.Color);
  }
}

执行的 SQL 语句如下所示:

exec sp_executesql N'SET NOCOUNT ON;
UPDATE [dbo].[Inventory] SET [Color] = @p0
WHERE [Id] = @p1 AND [TimeStamp] = @p2;
SELECT [TimeStamp]
FROM [dbo].[Inventory]
WHERE @@ROWCOUNT = 1 AND [Id] = @p1;

',N'@p1 int,@p0 nvarchar(50),@p2 varbinary(8)',@p1=1,@p0=N'White',@p2=0x000000000000862D

并发检查

前一章非常详细地介绍了并发检查。提醒一下,当一个实体定义了一个Timestamp属性时,当更改(更新或删除)被持久化到数据库时,该属性的值被用在where子句中。不是只搜索主键,而是将TimeStamp值添加到查询中,如下例所示:

UPDATE [dbo].[Inventory] SET [PetName] = @p0
WHERE [Id] = @p1 AND [TimeStamp] = @p2;

下面的测试展示了一个创建并发异常、捕获它并使用Entries获取原始值、当前值和当前存储在数据库中的值的示例。获取当前值需要另一个数据库调用。

[Fact]
public void ShouldThrowConcurrencyException()
{
  ExecuteInATransaction(RunTheTest);

  void RunTheTest()
  {
    var car = Context.Cars.First();
    //Update the database outside of the context
    Context.Database.ExecuteSqlInterpolated(
      $"Update dbo.Inventory set Color="Pink" where Id = {car.Id}");
    car.Color = "Yellow";
    var ex = Assert.Throws<CustomConcurrencyException>(
      () => Context.SaveChanges());
    var entry = ((DbUpdateConcurrencyException) ex.InnerException)?.Entries[0];
    PropertyValues originalProps = entry.OriginalValues;
    PropertyValues currentProps = entry.CurrentValues;
    //This needs another database call
    PropertyValues databaseProps = entry.GetDatabaseValues();
  }
}

这里列出了执行的 SQL 调用。第一个是 update 语句,第二个是获取数据库值的调用。

exec sp_executesql N'SET NOCOUNT ON;
UPDATE [dbo].[Inventory] SET [Color] = @p0
WHERE [Id] = @p1 AND [TimeStamp] = @p2;
SELECT [TimeStamp]
FROM [dbo].[Inventory]
WHERE @@ROWCOUNT = 1 AND [Id] = @p1;'
,N'@p1 int,@p0 nvarchar(50),@p2 varbinary(8)',@p1=1,@p0=N'Yellow',@p2=0x0000000000008665

exec sp_executesql N'SELECT TOP(1) [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId], [i].[PetName], [i].[TimeStamp]
FROM [dbo].[Inventory] AS [i]
WHERE [i].[Id] = @__p_0',N'@__p_0 int',@__p_0=1

删除记录

通过在DbSet<T>上调用Remove()或者通过将其状态设置为Deleted,单个实体被标记为删除。通过调用DbSet<T>上的RemoveRange(),记录列表被标记为删除。移除过程将根据OnModelCreating()中配置的规则(或 EF 核心约定)对导航属性产生级联效应。如果由于级联策略而阻止删除,则会引发异常。

实体状态

当在被跟踪的实体上调用Remove()方法时,它的EntityState被设置为Deleted。删除语句成功执行后,实体从ChangeTracker中移除,其状态变为Detached。请注意,实体仍然存在于您的应用中,除非它已经超出范围并被垃圾收集。

删除跟踪的记录

删除过程反映了更新过程。一旦跟踪到一个实体,就在该实例上调用Remove(),然后调用SaveChanges()从数据库中删除记录。

[Fact]
public void ShouldRemoveACar()
{
  ExecuteInATransaction(RunTheTest);

  void RunTheTest()
  {
    var carCount = Context.Cars. Count();
    var car = Context.Cars.First(c => c.Id == 2);
    Context.Cars.Remove(car);
    Context.SaveChanges();
    var newCarCount = Context.Cars.Count();
    Assert.Equal(carCount - 1, newCarCount);
    Assert.Equal(
      EntityState.Detached,
      Context.Entry(car).State);
  }
}

调用SaveChanges()后,实体实例仍然存在,但不再在ChangeTracker中。检查EntityState时,状态为Detached

下面列出了为删除而执行的 SQL 调用:

exec sp_executesql N'SET NOCOUNT ON;
DELETE FROM [dbo].[Inventory]
WHERE [Id] = @p0 AND [TimeStamp] = @p1;
SELECT @@ROWCOUNT;'
,N'@p0 int,@p1 varbinary(8)',@p0=2,@p1=0x0000000000008680

删除未跟踪的实体

未被跟踪的实体可以删除记录,就像未被跟踪的实体可以更新记录一样。不同的是通过调用Remove() / RemoveRange()或者将状态设置为Deleted然后调用SaveChanges()来跟踪实体。

下面的示例读取一个未跟踪的记录,从该记录创建一个新的Car类实例,并更改一个属性(Color)。然后,它要么设置状态,要么在DbSet<T>上使用Remove()方法(取决于您取消注释的是哪一行)。测试然后调用SaveChanges()。所有额外的上下文都是为了确保上下文之间没有交叉。

[Fact]
public void ShouldRemoveACarUsingState()
{
  ExecuteInASharedTransaction(RunTheTest);

  void RunTheTest(IDbContextTransaction trans)
  {
    var carCount = Context.Cars.Count();
    var car = Context.Cars.AsNoTracking().First(c => c.Id == 2);
    var context2 = TestHelpers.GetSecondContext(Context, trans);
    //Either call Remove or modify the state
    context2.Entry(car).State = EntityState.Deleted;
    //context2.Cars.Remove(car);
    context2.SaveChanges();
    var newCarCount = Context.Cars.Count();
    Assert.Equal(carCount - 1, newCarCount);
    Assert.Equal(
      EntityState.Detached,
      Context.Entry(car).State);
  }
}

捕捉级联删除失败

当删除记录的尝试由于级联规则而失败时,EF Core 将抛出一个DbUpdateException。下面的测试展示了这一点:

[Fact]
public void ShouldFailToRemoveACar()
{
  ExecuteInATransaction(RunTheTest);

  void RunTheTest()
  {
    var car = Context.Cars.First(c => c.Id == 1);
    Context.Cars.Remove(car);
    Assert.Throws<CustomDbUpdateException>(
      ()=>Context.SaveChanges());
  }
}

并发检查

如果实体有一个TimeStamp属性,Delete 也使用并发检查。有关更多信息,请参见“更新记录”一节中的“并发检查”一节。

摘要

本章使用前一章中获得的知识来完成AutoLot数据库的数据访问层。您使用了 EF 核心命令行工具来搭建现有的数据库,将模型更新到最终版本,然后创建并应用迁移。添加了用于封装数据访问的存储库,带有示例数据的数据库初始化代码可以以可重复、可靠的方式删除和创建数据库。本章的其余部分集中于测试数据访问层。这就完成了我们的数据访问和实体框架核心之旅。*