EF Core 7.0 中的新增功能

EF Core 7.0(EF7)于 2022 年 11 月发布

提示

可通过从 GitHub 下载示例代码来运行和调试示例。 每个部分都链接到特定于该部分的源代码。

EF7 面向 .NET 6,因此可用于 .NET 6 (LTS).NET 7

示例模型

下面的许多示例都使用了一个简单的模型,其中包含博客、帖子、标记和作者:

public class Blog
{
    public Blog(string name)
    {
        Name = name;
    }

    public int Id { get; private set; }
    public string Name { get; set; }
    public List<Post> Posts { get; } = new();
}

public class Post
{
    public Post(string title, string content, DateTime publishedOn)
    {
        Title = title;
        Content = content;
        PublishedOn = publishedOn;
    }

    public int Id { get; private set; }
    public string Title { get; set; }
    public string Content { get; set; }
    public DateTime PublishedOn { get; set; }
    public Blog Blog { get; set; } = null!;
    public List<Tag> Tags { get; } = new();
    public Author? Author { get; set; }
    public PostMetadata? Metadata { get; set; }
}

public class FeaturedPost : Post
{
    public FeaturedPost(string title, string content, DateTime publishedOn, string promoText)
        : base(title, content, publishedOn)
    {
        PromoText = promoText;
    }

    public string PromoText { get; set; }
}

public class Tag
{
    public Tag(string id, string text)
    {
        Id = id;
        Text = text;
    }

    public string Id { get; private set; }
    public string Text { get; set; }
    public List<Post> Posts { get; } = new();
}

public class Author
{
    public Author(string name)
    {
        Name = name;
    }

    public int Id { get; private set; }
    public string Name { get; set; }
    public ContactDetails Contact { get; set; } = null!;
    public List<Post> Posts { get; } = new();
}

其中一些示例还使用聚合类型,这些类型在不同示例中以不同方式映射。 联系人有一种聚合类型:

public class ContactDetails
{
    public Address Address { get; set; } = null!;
    public string? Phone { get; set; }
}

public class Address
{
    public Address(string street, string city, string postcode, string country)
    {
        Street = street;
        City = city;
        Postcode = postcode;
        Country = country;
    }

    public string Street { get; set; }
    public string City { get; set; }
    public string Postcode { get; set; }
    public string Country { get; set; }
}

以及发布元数据的第二种聚合类型:

public class PostMetadata
{
    public PostMetadata(int views)
    {
        Views = views;
    }

    public int Views { get; set; }
    public List<SearchTerm> TopSearches { get; } = new();
    public List<Visits> TopGeographies { get; } = new();
    public List<PostUpdate> Updates { get; } = new();
}

public class SearchTerm
{
    public SearchTerm(string term, int count)
    {
        Term = term;
        Count = count;
    }

    public string Term { get; private set; }
    public int Count { get; private set; }
}

public class Visits
{
    public Visits(double latitude, double longitude, int count)
    {
        Latitude = latitude;
        Longitude = longitude;
        Count = count;
    }

    public double Latitude { get; private set; }
    public double Longitude { get; private set; }
    public int Count { get; private set; }
    public List<string>? Browsers { get; set; }
}

public class PostUpdate
{
    public PostUpdate(IPAddress postedFrom, DateTime updatedOn)
    {
        PostedFrom = postedFrom;
        UpdatedOn = updatedOn;
    }

    public IPAddress PostedFrom { get; private set; }
    public string? UpdatedBy { get; init; }
    public DateTime UpdatedOn { get; private set; }
    public List<Commit> Commits { get; } = new();
}

public class Commit
{
    public Commit(DateTime committedOn, string comment)
    {
        CommittedOn = committedOn;
        Comment = comment;
    }

    public DateTime CommittedOn { get; private set; }
    public string Comment { get; set; }
}

提示

可在 BlogsContext.cs 中找到示例模型。

JSON 列

大多数关系数据库支持包含 JSON 文档的列。 可以使用查询钻取这些列中的 JSON。 例如,这允许按文档元素进行筛选和排序,以及将元素从文档中投影到结果中。 JSON 列允许关系数据库采用文档数据库的一些特征,从而在两者之间创建有用的混合。

EF7 包含对 JSON 列的提供程序不可知的支持,以及 SQL Server 的实现。 此支持将允许从 .NET 类型生成的聚合映射到 JSON 文档。 常规 LINQ 查询可用于聚合,这些查询将转换为钻取到 JSON 所需的相应查询构造。 EF7 还支持更新和保存对 JSON 文档的更改。

备注

计划在 EF7 发布后对 JSON 的 SQLite 支持。 PostgreSQL 和 Pomelo MySQL 提供程序已包含对 JSON 列的一些支持。 我们将与这些提供程序的作者合作,将所有提供程序的 JSON 支持保持一致。

映射到 JSON 列

在 EF Core 中,使用 OwnsOneOwnsMany 定义聚合类型。 例如,请考虑用于存储联系信息的示例模型中的聚合类型:

public class ContactDetails
{
    public Address Address { get; set; } = null!;
    public string? Phone { get; set; }
}

public class Address
{
    public Address(string street, string city, string postcode, string country)
    {
        Street = street;
        City = city;
        Postcode = postcode;
        Country = country;
    }

    public string Street { get; set; }
    public string City { get; set; }
    public string Postcode { get; set; }
    public string Country { get; set; }
}

然后,例如,可以在“所有者”实体类型中使用此实体类型来存储作者的详细联系信息:

public class Author
{
    public int Id { get; set; }
    public string Name { get; set; }
    public ContactDetails Contact { get; set; }
}

使用 OwnsOneOnModelCreating 中配置聚合类型:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Author>().OwnsOne(
        author => author.Contact, ownedNavigationBuilder =>
        {
            ownedNavigationBuilder.OwnsOne(contactDetails => contactDetails.Address);
        });
}

提示

此处显示的代码来自 JsonColumnsSample.cs

默认情况下,关系数据库提供程序会将此类聚合类型映射到与拥有实体类型相同的表。 也就是说,ContactDetailsAddress 类的每个属性都映射到 Authors 表中的列。

包含详细联系信息的一些已保存的作者如下所示:

作者

Id 名称 Contact_Address_Street Contact_Address_City Contact_Address_Postcode Contact_Address_Country Contact_Phone
1 Maddy Montaquila 1 Main St Camberwick Green CW1 5ZH 英国 01632 12345
2 Jeremy Likness 2 Main St Chigley CW1 5ZH 英国 01632 12346
3 Daniel Roth 3 Main St Camberwick Green CW1 5ZH 英国 01632 12347
4 Arthur Vickers 15a Main St Chigley CW1 5ZH 英国 01632 22345
5 Brice Lambson 4 Main St Chigley CW1 5ZH 英国 01632 12349

如果需要,组成聚合的每个实体类型都可以改为映射到自己的表:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Author>().OwnsOne(
        author => author.Contact, ownedNavigationBuilder =>
        {
            ownedNavigationBuilder.ToTable("Contacts");
            ownedNavigationBuilder.OwnsOne(
                contactDetails => contactDetails.Address, ownedOwnedNavigationBuilder =>
                {
                    ownedOwnedNavigationBuilder.ToTable("Addresses");
                });
        });
}

然后,同一数据存储在三个表中:

作者

Id 名称
1 Maddy Montaquila
2 Jeremy Likness
3 Daniel Roth
4 Arthur Vickers
5 Brice Lambson

联系人

AuthorId 电话
1 01632 12345
2 01632 12346
3 01632 12347
4 01632 22345
5 01632 12349

地址

ContactDetailsAuthorId 街道 城市 Postcode 国家/地区
1 1 Main St Camberwick Green CW1 5ZH 英国
2 2 Main St Chigley CW1 5ZH 英国
3 3 Main St Camberwick Green CW1 5ZH 英国
4 15a Main St Chigley CW1 5ZH 英国
5 4 Main St Chigley CW1 5ZH 英国

现在是有趣的部分。 在 EF7 中,可以将 ContactDetails 聚合类型映射到 JSON 列。 这只需在配置聚合类型时调用 ToJson()

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Author>().OwnsOne(
        author => author.Contact, ownedNavigationBuilder =>
        {
            ownedNavigationBuilder.ToJson();
            ownedNavigationBuilder.OwnsOne(contactDetails => contactDetails.Address);
        });
}

Authors 表现在将包含一个 JSON 列,用于填充每个作者的 JSON 文档 ContactDetails

作者

Id 名称 联系人
1 Maddy Montaquila "
  "Phone":"01632 12345",
  "Address": {
    "City":"Camberwick Green",
    "Country":"UK",
    "Postcode":"CW1 5ZH",
    "Street":"1 Main St"
  }
}
2 Jeremy Likness "
  "Phone":"01632 12346",
  "Address": {
    "City":"Chigley",
    "Country":"UK",
    "Postcode":"CH1 5ZH",
    "Street":"2 Main St"
  }
}
3 Daniel Roth "
  "Phone":"01632 12347",
  "Address": {
    "City":"Camberwick Green",
    "Country":"UK",
    "Postcode":"CW1 5ZH",
    "Street":"3 Main St"
  }
}
4 Arthur Vickers "
  "Phone":"01632 12348",
  "Address": {
    "City":"Chigley",
    "Country":"UK",
    "Postcode":"CH1 5ZH",
    "Street":"15a Main St"
  }
}
5 Brice Lambson "
  "Phone":"01632 12349",
  "Address": {
    "City":"Chigley",
    "Country":"UK",
    "Postcode":"CH1 5ZH",
    "Street":"4 Main St"
  }
}

提示

使用适用于 Azure Cosmos DB 的 EF Core 提供程序时,这种聚合的使用与 JSON 文档的映射方式非常相似。 JSON 列将 EF Core 与文档数据库配合使用的功能引入关系数据库中嵌入的文档。

上面显示的 JSON 文档非常简单,但此映射功能也可用于更复杂的文档结构。 例如,请考虑示例模型中的另一种聚合类型,用于表示有关帖子的元数据:

public class PostMetadata
{
    public PostMetadata(int views)
    {
        Views = views;
    }

    public int Views { get; set; }
    public List<SearchTerm> TopSearches { get; } = new();
    public List<Visits> TopGeographies { get; } = new();
    public List<PostUpdate> Updates { get; } = new();
}

public class SearchTerm
{
    public SearchTerm(string term, int count)
    {
        Term = term;
        Count = count;
    }

    public string Term { get; private set; }
    public int Count { get; private set; }
}

public class Visits
{
    public Visits(double latitude, double longitude, int count)
    {
        Latitude = latitude;
        Longitude = longitude;
        Count = count;
    }

    public double Latitude { get; private set; }
    public double Longitude { get; private set; }
    public int Count { get; private set; }
    public List<string>? Browsers { get; set; }
}

public class PostUpdate
{
    public PostUpdate(IPAddress postedFrom, DateTime updatedOn)
    {
        PostedFrom = postedFrom;
        UpdatedOn = updatedOn;
    }

    public IPAddress PostedFrom { get; private set; }
    public string? UpdatedBy { get; init; }
    public DateTime UpdatedOn { get; private set; }
    public List<Commit> Commits { get; } = new();
}

public class Commit
{
    public Commit(DateTime committedOn, string comment)
    {
        CommittedOn = committedOn;
        Comment = comment;
    }

    public DateTime CommittedOn { get; private set; }
    public string Comment { get; set; }
}

此聚合类型包含多个嵌套类型和集合。 调用 OwnsOneOwnsMany 用于映射此聚合类型:

modelBuilder.Entity<Post>().OwnsOne(
    post => post.Metadata, ownedNavigationBuilder =>
    {
        ownedNavigationBuilder.ToJson();
        ownedNavigationBuilder.OwnsMany(metadata => metadata.TopSearches);
        ownedNavigationBuilder.OwnsMany(metadata => metadata.TopGeographies);
        ownedNavigationBuilder.OwnsMany(
            metadata => metadata.Updates,
            ownedOwnedNavigationBuilder => ownedOwnedNavigationBuilder.OwnsMany(update => update.Commits));
    });

提示

仅聚合根目录需要 ToJson 才能将整个聚合映射到 JSON 文档。

使用此映射,EF7 可以创建并查询复杂的 JSON 文档,如下所示:

{
  "Views": 5085,
  "TopGeographies": [
    {
      "Browsers": "Firefox, Netscape",
      "Count": 924,
      "Latitude": 110.793,
      "Longitude": 39.2431
    },
    {
      "Browsers": "Firefox, Netscape",
      "Count": 885,
      "Latitude": 133.793,
      "Longitude": 45.2431
    }
  ],
  "TopSearches": [
    {
      "Count": 9359,
      "Term": "Search #1"
    }
  ],
  "Updates": [
    {
      "PostedFrom": "127.0.0.1",
      "UpdatedBy": "Admin",
      "UpdatedOn": "1996-02-17T19:24:29.5429092Z",
      "Commits": []
    },
    {
      "PostedFrom": "127.0.0.1",
      "UpdatedBy": "Admin",
      "UpdatedOn": "2019-11-24T19:24:29.5429093Z",
      "Commits": [
        {
          "Comment": "Commit #1",
          "CommittedOn": "2022-08-21T00:00:00+01:00"
        }
      ]
    },
    {
      "PostedFrom": "127.0.0.1",
      "UpdatedBy": "Admin",
      "UpdatedOn": "1997-05-28T19:24:29.5429097Z",
      "Commits": [
        {
          "Comment": "Commit #1",
          "CommittedOn": "2022-08-21T00:00:00+01:00"
        },
        {
          "Comment": "Commit #2",
          "CommittedOn": "2022-08-21T00:00:00+01:00"
        }
      ]
    }
  ]
}

备注

尚不支持将空间类型直接映射到 JSON。 上面的文档使用 double 值作为解决方法。 如果这是你感兴趣的内容,请投票支持 JSON 列中支持空间类型

备注

尚不支持将基元类型的集合映射到 JSON。 上面的文档使用值转换器将集合转换为逗号分隔的字符串。 如果这是你感兴趣的内容,请投票支持 Json:添加对基元类型的集合的支持

备注

尚不支持与 TPT 或 TPC 继承联合将拥有的类型映射到 JSON。 如果这是你感兴趣的内容,请投票支持具有 TPT/TPC 继承映射的 JSON 属性

对 JSON 列的查询

对 JSON 列的查询的工作方式与在 EF Core 中查询任何其他聚合类型相同。 也就是说,只需使用 LINQ! 下面是一些示例。

查询所有生活在 Chigley 的作者:

var authorsInChigley = await context.Authors
    .Where(author => author.Contact.Address.City == "Chigley")
    .ToListAsync();

使用 SQL Server 时,此查询将生成以下 SQL:

SELECT [a].[Id], [a].[Name], JSON_QUERY([a].[Contact],'$')
FROM [Authors] AS [a]
WHERE CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max)) = N'Chigley'

请注意,使用 JSON_VALUE 从 JSON 文档内的 Address 获取 City

Select 可用于从 JSON 文档提取和投影元素:

var postcodesInChigley = await context.Authors
    .Where(author => author.Contact.Address.City == "Chigley")
    .Select(author => author.Contact.Address.Postcode)
    .ToListAsync();

此查询生成以下 SQL:

SELECT CAST(JSON_VALUE([a].[Contact],'$.Address.Postcode') AS nvarchar(max))
FROM [Authors] AS [a]
WHERE CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max)) = N'Chigley'

下面是在筛选器和投影中执行更多操作的示例,还按 JSON 文档中的电话号码排序:

var orderedAddresses = await context.Authors
    .Where(
        author => (author.Contact.Address.City == "Chigley"
                   && author.Contact.Phone != null)
                  || author.Name.StartsWith("D"))
    .OrderBy(author => author.Contact.Phone)
    .Select(
        author => author.Name + " (" + author.Contact.Address.Street
                  + ", " + author.Contact.Address.City
                  + " " + author.Contact.Address.Postcode + ")")
    .ToListAsync();

此查询生成以下 SQL:

SELECT (((((([a].[Name] + N' (') + CAST(JSON_VALUE([a].[Contact],'$.Address.Street') AS nvarchar(max))) + N', ') + CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max))) + N' ') + CAST(JSON_VALUE([a].[Contact],'$.Address.Postcode') AS nvarchar(max))) + N')'
FROM [Authors] AS [a]
WHERE (CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max)) = N'Chigley' AND CAST(JSON_VALUE([a].[Contact],'$.Phone') AS nvarchar(max)) IS NOT NULL) OR ([a].[Name] LIKE N'D%')
ORDER BY CAST(JSON_VALUE([a].[Contact],'$.Phone') AS nvarchar(max))

当 JSON 文档包含集合时,可以在结果中投影这些集合:

var postsWithViews = await context.Posts.Where(post => post.Metadata!.Views > 3000)
    .AsNoTracking()
    .Select(
        post => new
        {
            post.Author!.Name, post.Metadata!.Views, Searches = post.Metadata.TopSearches, Commits = post.Metadata.Updates
        })
    .ToListAsync();

此查询生成以下 SQL:

SELECT [a].[Name], CAST(JSON_VALUE([p].[Metadata],'$.Views') AS int), JSON_QUERY([p].[Metadata],'$.TopSearches'), [p].[Id], JSON_QUERY([p].[Metadata],'$.Updates')
FROM [Posts] AS [p]
LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
WHERE CAST(JSON_VALUE([p].[Metadata],'$.Views') AS int) > 3000

备注

涉及 JSON 集合的更复杂的查询需要 jsonpath 支持。 如果这是你感兴趣的内容,请投票支持 jsonpath 查询

提示

请考虑创建索引以提高 JSON 文档中的查询性能。 例如,使用 SQL Server 时请参阅索引 Json 数据

更新 JSON 列

SaveChangesSaveChangesAsync 以正常方式对 JSON 列进行更新。 对于广泛的更改,将更新整个文档。 例如,替换作者的大部分 Contact 文档:

var jeremy = await context.Authors.SingleAsync(author => author.Name.StartsWith("Jeremy"));

jeremy.Contact = new() { Address = new("2 Riverside", "Trimbridge", "TB1 5ZS", "UK"), Phone = "01632 88346" };

await context.SaveChangesAsync();

在这种情况下,整个新文档作为参数传递:

info: 8/30/2022 20:21:24.392 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p0='{"Phone":"01632 88346","Address":{"City":"Trimbridge","Country":"UK","Postcode":"TB1 5ZS","Street":"2 Riverside"}}' (Nullable = false) (Size = 114), @p1='2'], CommandType='Text', CommandTimeout='30']

然后在 UPDATE SQL 中使用:

UPDATE [Authors] SET [Contact] = @p0
OUTPUT 1
WHERE [Id] = @p1;

但是,如果仅更改了子文档,EF Core 将使用 JSON_MODIFY 命令仅更新子文档。 例如,更改 Contact 文档中的 Address

var brice = await context.Authors.SingleAsync(author => author.Name.StartsWith("Brice"));

brice.Contact.Address = new("4 Riverside", "Trimbridge", "TB1 5ZS", "UK");

await context.SaveChangesAsync();

生成以下参数:

info: 10/2/2022 15:51:15.895 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p0='{"City":"Trimbridge","Country":"UK","Postcode":"TB1 5ZS","Street":"4 Riverside"}' (Nullable = false) (Size = 80), @p1='5'], CommandType='Text', CommandTimeout='30']

通过 JSON_MODIFY 调用在 UPDATE 中使用:

UPDATE [Authors] SET [Contact] = JSON_MODIFY([Contact], 'strict $.Address', JSON_QUERY(@p0))
OUTPUT 1
WHERE [Id] = @p1;

最后,如果只更改了单个属性,EF Core 将再次使用“JSON_MODIFY”命令,这次只修补更改的属性值。 例如:

var arthur = await context.Authors.SingleAsync(author => author.Name.StartsWith("Arthur"));

arthur.Contact.Address.Country = "United Kingdom";

await context.SaveChangesAsync();

生成以下参数:

info: 10/2/2022 15:54:05.112 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p0='["United Kingdom"]' (Nullable = false) (Size = 18), @p1='4'], CommandType='Text', CommandTimeout='30']

这又与 JSON_MODIFY 一起使用:

UPDATE [Authors] SET [Contact] = JSON_MODIFY([Contact], 'strict $.Address.Country', JSON_VALUE(@p0, '$[0]'))
OUTPUT 1
WHERE [Id] = @p1;

ExecuteUpdate 和 ExecuteDelete(“批量更新”)

默认情况下,EF Core 跟踪对实体的更改,然后在调用其中一个 SaveChanges方法时, 向数据库 发送更新。 仅针对实际更改的属性和关系发送更改。 此外,跟踪的实体与发送到数据库的更改保持同步。 此机制是向数据库发送常规用途插入、更新和删除的高效便捷方法。 这些更改也会进行批处理,以减少数据库往返次数。

但是,有时在数据库上执行更新或删除命令会很有用,而无需涉及更改跟踪器。 EF7 使用新的 ExecuteUpdateExecuteDelete 方法启用此功能。 这些方法将应用于 LINQ 查询,并根据该查询的结果更新或删除数据库中的实体。 许多实体可以使用单个命令进行更新,并且实体不会加载到内存中,这意味着这可能会导致更高效的更新和删除。

但是,请记住:

  • 必须显式指定要做出的特定更改;EF Core 不会自动检测到它们。
  • 任何跟踪的实体都不会保持同步。
  • 可能需要按正确的顺序发送其他命令,以免违反数据库约束。 例如,删除依赖项,然后才能删除主体。

这一切意味着 ExecuteUpdateExecuteDelete 方法补充了现有的 SaveChanges 机制,而不是替换这些机制。

基本 ExecuteDelete 示例

提示

此处显示的代码来自 ExecuteDeleteSample.cs

DbSet 调用 ExecuteDeleteExecuteDeleteAsync 会立即从数据库中删除该 DbSet 的所有实体。 例如,删除所有 Tag 实体:

await context.Tags.ExecuteDeleteAsync();

使用 SQL Server 时,这将执行以下 SQL:

DELETE FROM [t]
FROM [Tags] AS [t]

更有趣的是,查询可以包含筛选器。 例如:

await context.Tags.Where(t => t.Text.Contains(".NET")).ExecuteDeleteAsync();

这会执行以下 SQL:

DELETE FROM [t]
FROM [Tags] AS [t]
WHERE [t].[Text] LIKE N'%.NET%'

查询还可以使用更复杂的筛选器,包括指向其他类型的导航。 例如,仅从旧博客文章中删除标记:

await context.Tags.Where(t => t.Posts.All(e => e.PublishedOn.Year < 2022)).ExecuteDeleteAsync();

执行操作:

DELETE FROM [t]
FROM [Tags] AS [t]
WHERE NOT EXISTS (
    SELECT 1
    FROM [PostTag] AS [p]
    INNER JOIN [Posts] AS [p0] ON [p].[PostsId] = [p0].[Id]
    WHERE [t].[Id] = [p].[TagsId] AND NOT (DATEPART(year, [p0].[PublishedOn]) < 2022))

基本 ExecuteUpdate 示例

提示

此处显示的代码来自 ExecuteUpdateSample.cs

ExecuteUpdateExecuteUpdateAsync 的行为方式与 ExecuteDelete 方法非常相似。 主要区别在于,更新需要了解更新哪些属性,以及更新的方式。 这是使用一个或多个调用 SetProperty 实现的。 例如,若要更新每个博客的 Name

await context.Blogs.ExecuteUpdateAsync(
    s => s.SetProperty(b => b.Name, b => b.Name + " *Featured!*"));

SetProperty 的第一个参数指定要更新的属性;在本例中,Blog.Name。 第二个参数指定应如何计算新值;在本例中,通过获取现有值并追加 "*Featured!*"。 生成的 SQL 为:

UPDATE [b]
SET [b].[Name] = [b].[Name] + N' *Featured!*'
FROM [Blogs] AS [b]

ExecuteDelete 一样,查询可用于筛选更新的实体。 此外,可以使用多个调用 SetProperty 来更新目标实体上的多个属性。 例如,若要更新 2022 年之前发布的所有文章的 TitleContent

await context.Posts
    .Where(p => p.PublishedOn.Year < 2022)
    .ExecuteUpdateAsync(s => s
        .SetProperty(b => b.Title, b => b.Title + " (" + b.PublishedOn.Year + ")")
        .SetProperty(b => b.Content, b => b.Content + " ( This content was published in " + b.PublishedOn.Year + ")"));

在这种情况下,生成的 SQL 稍微复杂一些:

UPDATE [p]
SET [p].[Content] = (([p].[Content] + N' ( This content was published in ') + COALESCE(CAST(DATEPART(year, [p].[PublishedOn]) AS nvarchar(max)), N'')) + N')',
    [p].[Title] = (([p].[Title] + N' (') + COALESCE(CAST(DATEPART(year, [p].[PublishedOn]) AS nvarchar(max)), N'')) + N')'
FROM [Posts] AS [p]
WHERE DATEPART(year, [p].[PublishedOn]) < 2022

最后,与 ExecuteDelete 一样,筛选器可以引用其他表。 例如,若要更新旧文章中的所有标记:

await context.Tags
    .Where(t => t.Posts.All(e => e.PublishedOn.Year < 2022))
    .ExecuteUpdateAsync(s => s.SetProperty(t => t.Text, t => t.Text + " (old)"));

生成:

UPDATE [t]
SET [t].[Text] = [t].[Text] + N' (old)'
FROM [Tags] AS [t]
WHERE NOT EXISTS (
    SELECT 1
    FROM [PostTag] AS [p]
    INNER JOIN [Posts] AS [p0] ON [p].[PostsId] = [p0].[Id]
    WHERE [t].[Id] = [p].[TagsId] AND NOT (DATEPART(year, [p0].[PublishedOn]) < 2022))

有关 ExecuteUpdateExecuteDelete 的详细信息和代码示例,请参阅 ExecuteUpdate 和 ExecuteDelete

继承和多个表

ExecuteUpdateExecuteDelete 只能对单个表执行操作。 这在处理不同的继承映射策略时有影响。 通常,使用 TPH 映射策略时没有问题,因为只有一个表需要修改。 例如,删除所有 FeaturedPost 实体:

await context.Set<FeaturedPost>().ExecuteDeleteAsync();

使用 TPH 映射时,生成以下 SQL:

DELETE FROM [p]
FROM [Posts] AS [p]
WHERE [p].[Discriminator] = N'FeaturedPost'

使用 TPC 映射策略时,这种情况也没有问题,因为只需要再次更改单个表:

DELETE FROM [f]
FROM [FeaturedPosts] AS [f]

但是,在使用 TPT 映射策略时尝试此操作会失败,因为它需要从两个不同的表中删除行。

向查询添加筛选器通常意味着使用 TPC 和 TPT 策略的操作会失败。 这也是因为可能需要从多个表中删除行。 例如下面的查询:

await context.Posts.Where(p => p.Author!.Name.StartsWith("Arthur")).ExecuteDeleteAsync();

使用 TPH 时,生成以下 SQL:

DELETE FROM [p]
FROM [Posts] AS [p]
LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
WHERE [a].[Name] IS NOT NULL AND ([a].[Name] LIKE N'Arthur%')

但在使用 TPC 或 TPT 时失败。

提示

问题 #10879 跟踪在这些方案中自动发送多个命令的支持。 如果这是你想要看到实现的问题,请投票支持此问题。

ExecuteDelete 和关系

如上所述,可能需要删除或更新依赖实体,然后才能删除关系主体。 例如,每个 Post 都依赖于其关联的 Author。 这意味着如果文章仍引用作者,则无法删除作者;这样做将违反数据库中的外键约束。 例如,尝试此操作:

await context.Authors.ExecuteDeleteAsync();

将导致 SQL Server 出现以下异常:

Microsoft.Data.SqlClient.SqlException (0x80131904):DELETE 语句与 REFERENCE 约束“FK_Posts_Authors_AuthorId”冲突。 数据库“TphBlogsContext”、表“dbo.Posts”、列“AuthorId”中发生冲突。 语句已终止。

若要解决此问题,必须先删除文章,或者通过将 AuthorId 外键属性设置为 null 来断绝每篇文章与其作者之间的关系。 例如,使用删除选项:

await context.Posts.TagWith("Deleting posts...").ExecuteDeleteAsync();
await context.Authors.TagWith("Deleting authors...").ExecuteDeleteAsync();

提示

TagWith 可以像标记普通查询一样标记 ExecuteDeleteExecuteUpdate

这会导致两个单独的命令;第一个删除依赖项:

-- Deleting posts...

DELETE FROM [p]
FROM [Posts] AS [p]

第二个删除主体:

-- Deleting authors...

DELETE FROM [a]
FROM [Authors] AS [a]

重要

默认情况下,多个 ExecuteDeleteExecuteUpdate 命令默认不会包含在单个事务中。 但是,DbContext 事务 API 可按正常的方式用于在事务中包装这些命令。

提示

在单次往返中发送这些命令取决于问题 #10879。 如果这是你想要看到实现的问题,请投票支持此问题。

在此数据库中配置级联删除非常有用。 在我们的模型中,需要 BlogPost 之间的关系,这会导致 EF Core 按约定配置级联删除。 这意味着从数据库中删除博客时,也会删除其所有依赖文章。 由此得出结论,若要删除所有博客和文章,我们只需要删除博客:

await context.Blogs.ExecuteDeleteAsync();

这将产生以下 SQL:

DELETE FROM [b]
FROM [Blogs] AS [b]

删除博客时,这也将导致配置级联删除删除所有相关文章。

更快的 SaveChanges

在 EF7 中,SaveChangesSaveChangesAsync 的性能得到了显著改善。 在某些情况下,保存更改的速度比 EF Core 6.0 快四倍!

其中大多数改进来自:

  • 执行到数据库的往返次数更少
  • 生成更快的 SQL

下面显示了这些改进的一些示例。

备注

有关这些更改的深入讨论,请参阅 .NET 博客上的宣布推出 Entity Framework Core 7 预览版 6:性能版本

提示

此处显示的代码来自 SaveChangesPerformanceSample.cs

消除不需要的事务

所有新式关系数据库都保证(大多数)单个 SQL 语句的事务性。 也就是说,即使发生错误,语句也永远不会部分完成。 EF7 避免在这些情况下启动显式事务。

例如,查看对 SaveChanges 的以下调用的日志记录:

await context.AddAsync(new Blog { Name = "MyBlog" });
await context.SaveChangesAsync();

显示在 EF Core 6.0 中,INSERT 命令由命令包装来启动,然后提交事务:

dbug: 9/29/2022 11:43:09.196 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 9/29/2022 11:43:09.265 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (27ms) [Parameters=[@p0='MyBlog' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      VALUES (@p0);
      SELECT [Id]
      FROM [Blogs]
      WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();
dbug: 9/29/2022 11:43:09.297 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

EF7 检测到此处不需要事务,因此删除以下调用:

info: 9/29/2022 11:42:34.776 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (25ms) [Parameters=[@p0='MyBlog' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      OUTPUT INSERTED.[Id]
      VALUES (@p0);

这消除了两个数据库往返,这可能会对整体性能产生巨大影响,尤其是在对数据库的调用延迟较高时。 在典型的生产系统中,数据库与应用程序不在同一台计算机上。 这意味着延迟通常相对较高,使得这种优化在实际生产系统中特别有效。

改进了用于简单标识插入的 SQL

上述情况插入具有 IDENTITY 键列的单个行,并且没有其他数据库生成的值。 在这种情况下,EF7 使用 OUTPUT INSERTED 来简化 SQL。 尽管这种简化对许多其他情况无效,但改进仍然很重要,因为此类单行插入在许多应用程序中非常常见。

插入多个行

在 EF Core 6.0 中,插入多行的默认方法受 SQL Server 对具有触发器的表的支持限制所驱动。 我们希望确保即使在表中具有触发器的少数用户,默认体验也能正常工作。 这意味着我们不能使用简单的 OUTPUT 子句,因为在 SQL Server 上,此 不适用于触发器。 相反,在插入多个实体时,EF Core 6.0 会生成一些相当卷积的 SQL。 例如,此调用 SaveChanges

for (var i = 0; i < 4; i++)
{
    await context.AddAsync(new Blog { Name = "Foo" + i });
}

await context.SaveChangesAsync();

使用 EF Core 6.0 针对 SQL Server 运行时,将产生以下操作:

dbug: 9/30/2022 17:19:51.919 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 9/30/2022 17:19:51.993 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (27ms) [Parameters=[@p0='Foo0' (Nullable = false) (Size = 4000), @p1='Foo1' (Nullable = false) (Size = 4000), @p2='Foo2' (Nullable = false) (Size = 4000), @p3='Foo3' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      DECLARE @inserted0 TABLE ([Id] int, [_Position] [int]);
      MERGE [Blogs] USING (
      VALUES (@p0, 0),
      (@p1, 1),
      (@p2, 2),
      (@p3, 3)) AS i ([Name], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name])
      VALUES (i.[Name])
      OUTPUT INSERTED.[Id], i._Position
      INTO @inserted0;

      SELECT [i].[Id] FROM @inserted0 i
      ORDER BY [i].[_Position];
dbug: 9/30/2022 17:19:52.023 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

重要

尽管这很复杂,但批处理多个插入的速度仍然明显快于为每个插入发送单个命令。

在 EF7 中,如果表包含触发器,仍然可以获取此 SQL,但对于常见情况,我们现在生成的效率要高得多(尽管仍然有些复杂)命令:

info: 9/30/2022 17:40:37.612 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (4ms) [Parameters=[@p0='Foo0' (Nullable = false) (Size = 4000), @p1='Foo1' (Nullable = false) (Size = 4000), @p2='Foo2' (Nullable = false) (Size = 4000), @p3='Foo3' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      MERGE [Blogs] USING (
      VALUES (@p0, 0),
      (@p1, 1),
      (@p2, 2),
      (@p3, 3)) AS i ([Name], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name])
      VALUES (i.[Name])
      OUTPUT INSERTED.[Id], i._Position;

事务已消失,就像在单个插入事例中一样,因为 MERGE 是受隐式事务保护的单个语句。 此外,临时表已消失,OUTPUT 子句现在会将生成的 ID 直接发送回客户端。 这比 EF Core 6.0 快四倍,具体取决于应用程序与数据库之间的延迟等环境因素。

触发器

如果表具有触发器,则调用上述代码中的 SaveChanges 将引发异常:

未经处理的异常。 Microsoft.EntityFrameworkCore.DbUpdateException:
无法保存更改,因为目标表具有数据库触发器。 请相应地配置实体类型,有关详细信息,请参阅 https://aka.ms/efcore-docs-sqlserver-save-changes-and-triggers
---> Microsoft.Data.SqlClient.SqlException (0x80131904):
如果 DML 语句包含不带 INTO 子句的 OUTPUT 子句,则该语句的目标表 'BlogsWithTriggers' 不能具有任何启用的触发器。

以下代码可用于通知 EF Core 表具有触发器:

modelBuilder
    .Entity<BlogWithTrigger>()
    .ToTable(tb => tb.HasTrigger("TRG_InsertUpdateBlog"));

然后,在为此表发送插入和更新命令时,EF7 将还原为 EF Core 6.0 SQL。

有关详细信息,包括使用触发器自动配置所有映射表的约定,请参阅 EF7 中断性变更文档中具有触发器的 SQL Server 表现在需要特殊的 EF Core 配置

插入图形的往返次数更少

请考虑插入包含新主体实体的实体图,以及具有引用新主体的外键的新依赖实体。 例如:

await context.AddAsync(
    new Blog { Name = "MyBlog", Posts = { new() { Title = "My first post" }, new() { Title = "My second post" } } });
await context.SaveChangesAsync();

如果主体的主键由数据库生成,则在插入主体之前,在依赖项中为外键设置的值未知。 EF Core 为此生成两个往返,用于插入主体并返回新的主键,另一个用于插入具有外键值的依赖项。 由于有两个语句,因此需要一个事务,这意味着总共有四个往返:

dbug: 10/1/2022 13:12:02.517 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 10/1/2022 13:12:02.517 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='MyBlog' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      OUTPUT INSERTED.[Id]
      VALUES (@p0);
info: 10/1/2022 13:12:02.529 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (5ms) [Parameters=[@p1='6', @p2='My first post' (Nullable = false) (Size = 4000), @p3='6', @p4='My second post' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      MERGE [Post] USING (
      VALUES (@p1, @p2, 0),
      (@p3, @p4, 1)) AS i ([BlogId], [Title], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([BlogId], [Title])
      VALUES (i.[BlogId], i.[Title])
      OUTPUT INSERTED.[Id], i._Position;
dbug: 10/1/2022 13:12:02.531 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

但是,在某些情况下,主键值在插入主体之前是已知的。 这包括:

  • 未自动生成的键值
  • 在客户端上生成的键值,例如 Guid 密钥
  • 在服务器上批量生成的键值,例如使用 hi-lo 值生成器时

在 EF7 中,这些情况现在已优化为单次往返。 例如,在 SQL Server 上,可以将 Blog.Id 主键配置为使用 hi-lo 生成策略:

modelBuilder.Entity<Blog>().Property(e => e.Id).UseHiLo();
modelBuilder.Entity<Post>().Property(e => e.Id).UseHiLo();

现在,上述 SaveChanges 调用已优化为插入的单个往返。

dbug: 10/1/2022 21:51:55.805 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 10/1/2022 21:51:55.806 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='9', @p1='MyBlog' (Nullable = false) (Size = 4000), @p2='10', @p3='9', @p4='My first post' (Nullable = false) (Size = 4000), @p5='11', @p6='9', @p7='My second post' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Id], [Name])
      VALUES (@p0, @p1);
      INSERT INTO [Posts] ([Id], [BlogId], [Title])
      VALUES (@p2, @p3, @p4),
      (@p5, @p6, @p7);
dbug: 10/1/2022 21:51:55.807 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

请注意,此处仍需要事务。 这是因为要插入到两个单独的表中。

在其他情况下,EF7 还使用单个批处理,其中 EF Core 6.0 将创建多个。 例如,删除行并将其插入到同一表中时。

SaveChanges 的值

如此处的一些示例所示,将结果保存到数据库可能是一项复杂的业务。 这就是使用 EF Core 之类的内容真正显示其价值的地方。 EF Core:

  • 将多个插入、更新和删除命令批处理在一起以减少往返
  • 确定是否需要显式事务
  • 确定插入、更新和删除实体的顺序,以便不违反数据库约束
  • 确保有效地返回数据库生成的值,并将其传播回实体
  • 使用为主键生成的值自动设置外键值
  • 检测并发冲突

此外,对于其中许多情况,不同的数据库系统需要不同的 SQL。 EF Core 数据库提供程序适用于 EF Core,以确保为每个案例发送正确的高效命令。

每种具体类型一张表 (TPC) 继承映射

默认情况下,EF Core 会将 .NET 类型的继承层次结构映射到单个数据库表。 这称为每个层次结构一张表 (TPH) 映射。 EF Core 5.0 引入了每个类型一张表 (TPT) 策略,该策略支持将每个 .NET 类型映射到不同的数据库表。 EF7 引入了每个具体类型一张表 (TPC) 策略。 TPC 还会将 .NET 类型映射到不同的表,但通过这种方式解决了 TPT 策略的一些常见性能问题。

提示

此处显示的代码来自 TpcInheritanceSample.cs

提示

EF 团队在 .NET 数据社区站立的一集中演示并深入讨论了 TPC 映射。 与所有社区站立剧集一样,你可以观看 YouTube 上的 TPC 剧集

TPC 数据库架构

TPC 策略类似于 TPT 策略,除了为层次结构中每个具体类型创建不同的表,但表不是抽象类型(因此名称为“每个具体类型一张表”)创建。 与 TPT 一样,表本身指示已保存对象的类型。 但是,与 TPT 映射不同,每个表都包含具体类型及其基类型中每个属性的列。 TPC 数据库架构非规范化。

例如,请考虑映射以下层次结构:

public abstract class Animal
{
    protected Animal(string name)
    {
        Name = name;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public abstract string Species { get; }

    public Food? Food { get; set; }
}

public abstract class Pet : Animal
{
    protected Pet(string name)
        : base(name)
    {
    }

    public string? Vet { get; set; }

    public ICollection<Human> Humans { get; } = new List<Human>();
}

public class FarmAnimal : Animal
{
    public FarmAnimal(string name, string species)
        : base(name)
    {
        Species = species;
    }

    public override string Species { get; }

    [Precision(18, 2)]
    public decimal Value { get; set; }

    public override string ToString()
        => $"Farm animal '{Name}' ({Species}/{Id}) worth {Value:C} eats {Food?.ToString() ?? "<Unknown>"}";
}

public class Cat : Pet
{
    public Cat(string name, string educationLevel)
        : base(name)
    {
        EducationLevel = educationLevel;
    }

    public string EducationLevel { get; set; }
    public override string Species => "Felis catus";

    public override string ToString()
        => $"Cat '{Name}' ({Species}/{Id}) with education '{EducationLevel}' eats {Food?.ToString() ?? "<Unknown>"}";
}

public class Dog : Pet
{
    public Dog(string name, string favoriteToy)
        : base(name)
    {
        FavoriteToy = favoriteToy;
    }

    public string FavoriteToy { get; set; }
    public override string Species => "Canis familiaris";

    public override string ToString()
        => $"Dog '{Name}' ({Species}/{Id}) with favorite toy '{FavoriteToy}' eats {Food?.ToString() ?? "<Unknown>"}";
}

public class Human : Animal
{
    public Human(string name)
        : base(name)
    {
    }

    public override string Species => "Homo sapiens";

    public Animal? FavoriteAnimal { get; set; }
    public ICollection<Pet> Pets { get; } = new List<Pet>();

    public override string ToString()
        => $"Human '{Name}' ({Species}/{Id}) with favorite animal '{FavoriteAnimal?.Name ?? "<Unknown>"}'" +
           $" eats {Food?.ToString() ?? "<Unknown>"}";
}

使用 SQL Server 时,为此层次结构创建的表包括:

CREATE TABLE [Cats] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [Vet] nvarchar(max) NULL,
    [EducationLevel] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Cats] PRIMARY KEY ([Id]));

CREATE TABLE [Dogs] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [Vet] nvarchar(max) NULL,
    [FavoriteToy] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Dogs] PRIMARY KEY ([Id]));

CREATE TABLE [FarmAnimals] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [Value] decimal(18,2) NOT NULL,
    [Species] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_FarmAnimals] PRIMARY KEY ([Id]));

CREATE TABLE [Humans] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [FavoriteAnimalId] int NULL,
    CONSTRAINT [PK_Humans] PRIMARY KEY ([Id]));

请注意:

  • AnimalPet 类型没有表,因为这些表在对象模型中 abstract。 请记住,C# 不允许抽象类型的实例,因此不存在将抽象类型实例保存到数据库的情况。

  • 基类型中的属性映射为每个具体类型重复。 例如,每个表都有一个 Name 列,“猫”和“狗”都有一个 Vet 列。

  • 将某些数据保存到此数据库中会导致以下结果:

“猫”表

Id 名称 FoodId Vet EducationLevel
1 Alice 99ca3e98-b26d-4a0c-d4ae-08da7aca624f Pengelly MBA
2 Mac 99ca3e98-b26d-4a0c-d4ae-08da7aca624f Pengelly Preschool
8 Baxter 5dc5019e-6f72-454b-d4b0-08da7aca624f Bothell Pet Hospital BSc

“狗”表

Id 名称 FoodId Vet FavoriteToy
3 toast 011aaf6f-d588-4fad-d4ac-08da7aca624f Pengelly Mr. Squirrel

“农场动物”表

Id 名称 FoodId 种类
4 Clyde 1d495075-f527-4498-d4af-08da7aca624f 100.00 Equus africanus asinus

“人类”表

Id 名称 FoodId FavoriteAnimalId
5 Wendy 5418fd81-7660-432f-d4b1-08da7aca624f 2
6 Arthur 59b495d4-0414-46bf-d4ad-08da7aca624f 1
9 Katie null 8

请注意,与 TPT 映射不同,单个对象的所有信息都包含在单个表中。 而且,与 TPH 映射不同,任何表中的列和行都不存在模型从未使用过的组合。 下面将介绍这些特征对于查询和存储非常重要。

配置 TPC 继承

使用 EF Core 映射层次结构时,必须在模型中显式包含继承层次结构中的所有类型。 为此,可以在 DbContext 上为每个类型创建 DbSet 属性:

public DbSet<Animal> Animals => Set<Animal>();
public DbSet<Pet> Pets => Set<Pet>();
public DbSet<FarmAnimal> FarmAnimals => Set<FarmAnimal>();
public DbSet<Cat> Cats => Set<Cat>();
public DbSet<Dog> Dogs => Set<Dog>();
public DbSet<Human> Humans => Set<Human>();

或使用 OnModelCreating 中的 Entity 方法:

modelBuilder.Entity<Animal>();
modelBuilder.Entity<Pet>();
modelBuilder.Entity<Cat>();
modelBuilder.Entity<Dog>();
modelBuilder.Entity<FarmAnimal>();
modelBuilder.Entity<Human>();

重要

这不同于旧版 EF6 行为,如果映射基类型的派生类型包含在同一程序集中,则会自动发现这些类型的派生类型。

无需执行任何其他操作才能将层次结构映射为 TPH,因为它是默认策略。 但是,从 EF7 开始,可以通过对层次结构的基类型调用 UseTphMappingStrategy 来显式进行 TPH:

modelBuilder.Entity<Animal>().UseTphMappingStrategy();

若要改用 TPT,请将此项更改为 UseTptMappingStrategy

modelBuilder.Entity<Animal>().UseTptMappingStrategy();

同样,UseTpcMappingStrategy 用于配置 TPC:

modelBuilder.Entity<Animal>().UseTpcMappingStrategy();

在每种情况下,用于每种类型的表名都取自 DbContext 上的 DbSet 属性名称,或者可以使用 ToTable 生成器方法或 [Table] 属性配置

TPC 查询性能

对于查询,TPC 策略是对 TPT 的改进,因为它可确保给定实体实例的信息始终存储在单个表中。 这意味着,当映射层次结构很大且具有许多具体(通常是叶)类型时,TPC 策略非常有用,每个类型都具有大量属性,并且大多数查询中仅使用少量类型的子集。

为三个简单的 LINQ 查询生成的 SQL 可用于观察与 TPH 和 TPT 相比,TPC 在何处表现良好。 这些查询包括:

  1. 返回层次结构中所有类型的实体的查询:

    context.Animals.ToList();
    
  2. 从层次结构中类型的子集返回实体的查询:

    context.Pets.ToList();
    
  3. 仅从层次结构中的单个叶类型返回实体的查询:

    context.Cats.ToList();
    

TPH 查询

使用 TPH 时,所有三个查询仅查询单个表,但对鉴别器列使用不同的筛选器:

  1. TPH SQL 返回层次结构中所有类型的实体:

    SELECT [a].[Id], [a].[Discriminator], [a].[FoodId], [a].[Name], [a].[Species], [a].[Value], [a].[FavoriteAnimalId], [a].[Vet], [a].[EducationLevel], [a].[FavoriteToy]
    FROM [Animals] AS [a]
    
  2. TPH SQL 从层次结构中的类型子集返回实体:

    SELECT [a].[Id], [a].[Discriminator], [a].[FoodId], [a].[Name], [a].[Vet], [a].[EducationLevel], [a].[FavoriteToy]
    FROM [Animals] AS [a]
    WHERE [a].[Discriminator] IN (N'Cat', N'Dog')
    
  3. TPH SQL 仅从层次结构中的单个叶类型返回实体:

    SELECT [a].[Id], [a].[Discriminator], [a].[FoodId], [a].[Name], [a].[Vet], [a].[EducationLevel]
    FROM [Animals] AS [a]
    WHERE [a].[Discriminator] = N'Cat'
    

所有这些查询都应表现良好,尤其是对鉴别器列使用适当的数据库索引。

TPT 查询

使用 TPT 时,所有这些查询都需要联接多个表,因为任何给定具体类型的数据都拆分到多个表中:

  1. TPT SQL 返回层次结构中所有类型的实体:

    SELECT [a].[Id], [a].[FoodId], [a].[Name], [f].[Species], [f].[Value], [h].[FavoriteAnimalId], [p].[Vet], [c].[EducationLevel], [d].[FavoriteToy], CASE
        WHEN [d].[Id] IS NOT NULL THEN N'Dog'
        WHEN [c].[Id] IS NOT NULL THEN N'Cat'
        WHEN [h].[Id] IS NOT NULL THEN N'Human'
        WHEN [f].[Id] IS NOT NULL THEN N'FarmAnimal'
    END AS [Discriminator]
    FROM [Animals] AS [a]
    LEFT JOIN [FarmAnimals] AS [f] ON [a].[Id] = [f].[Id]
    LEFT JOIN [Humans] AS [h] ON [a].[Id] = [h].[Id]
    LEFT JOIN [Pets] AS [p] ON [a].[Id] = [p].[Id]
    LEFT JOIN [Cats] AS [c] ON [a].[Id] = [c].[Id]
    LEFT JOIN [Dogs] AS [d] ON [a].[Id] = [d].[Id]
    
  2. TPT SQL 从层次结构中的类型子集返回实体:

    SELECT [a].[Id], [a].[FoodId], [a].[Name], [p].[Vet], [c].[EducationLevel], [d].[FavoriteToy], CASE
        WHEN [d].[Id] IS NOT NULL THEN N'Dog'
        WHEN [c].[Id] IS NOT NULL THEN N'Cat'
    END AS [Discriminator]
    FROM [Animals] AS [a]
    INNER JOIN [Pets] AS [p] ON [a].[Id] = [p].[Id]
    LEFT JOIN [Cats] AS [c] ON [a].[Id] = [c].[Id]
    LEFT JOIN [Dogs] AS [d] ON [a].[Id] = [d].[Id]
    
  3. TPT SQL 仅从层次结构中的单个叶类型返回实体:

    SELECT [a].[Id], [a].[FoodId], [a].[Name], [p].[Vet], [c].[EducationLevel]
    FROM [Animals] AS [a]
    INNER JOIN [Pets] AS [p] ON [a].[Id] = [p].[Id]
    INNER JOIN [Cats] AS [c] ON [a].[Id] = [c].[Id]
    

备注

EF Core 使用“歧视性合成”来确定数据来自哪个表,因此使用的正确类型。 这样做是因为 LEFT JOIN 返回依赖 ID 列(“子表”)的 null 值,这些列不是正确的类型。 因此,对于狗,[d].[Id] 将为非 null,所有其他(具体)ID 将为 null。

由于表联接,所有这些查询都可能会导致性能问题。 这就是为什么 TPT 从来不是查询性能的好选择。

TPC 查询

TPC 在所有这些查询方面比 TPT 有所改进,因为需要查询的表格数量会减少。 此外,每个表的结果都使用 UNION ALL 进行组合,这比表联接要快得多,因为它不需要在行之间执行任何匹配,也不需要重复行。

  1. TPC SQL 返回层次结构中所有类型的实体:

    SELECT [f].[Id], [f].[FoodId], [f].[Name], [f].[Species], [f].[Value], NULL AS [FavoriteAnimalId], NULL AS [Vet], NULL AS [EducationLevel], NULL AS [FavoriteToy], N'FarmAnimal' AS [Discriminator]
    FROM [FarmAnimals] AS [f]
    UNION ALL
    SELECT [h].[Id], [h].[FoodId], [h].[Name], NULL AS [Species], NULL AS [Value], [h].[FavoriteAnimalId], NULL AS [Vet], NULL AS [EducationLevel], NULL AS [FavoriteToy], N'Human' AS [Discriminator]
    FROM [Humans] AS [h]
    UNION ALL
    SELECT [c].[Id], [c].[FoodId], [c].[Name], NULL AS [Species], NULL AS [Value], NULL AS [FavoriteAnimalId], [c].[Vet], [c].[EducationLevel], NULL AS [FavoriteToy], N'Cat' AS [Discriminator]
    FROM [Cats] AS [c]
    UNION ALL
    SELECT [d].[Id], [d].[FoodId], [d].[Name], NULL AS [Species], NULL AS [Value], NULL AS [FavoriteAnimalId], [d].[Vet], NULL AS [EducationLevel], [d].[FavoriteToy], N'Dog' AS [Discriminator]
    FROM [Dogs] AS [d]
    
  2. TPC SQL 从层次结构中的类型子集返回实体:

    SELECT [c].[Id], [c].[FoodId], [c].[Name], [c].[Vet], [c].[EducationLevel], NULL AS [FavoriteToy], N'Cat' AS [Discriminator]
    FROM [Cats] AS [c]
    UNION ALL
    SELECT [d].[Id], [d].[FoodId], [d].[Name], [d].[Vet], NULL AS [EducationLevel], [d].[FavoriteToy], N'Dog' AS [Discriminator]
    FROM [Dogs] AS [d]
    
  3. TPC SQL 仅从层次结构中的单个叶类型返回实体:

    SELECT [c].[Id], [c].[FoodId], [c].[Name], [c].[Vet], [c].[EducationLevel]
    FROM [Cats] AS [c]
    

尽管 TPC 在所有这些查询方面优于 TPT,但在返回多个类型的实例时,TPH 查询仍然更好。 这是 TPH 是 EF Core 使用的默认策略的原因之一。

正如用于查询 #3 的 SQL 所示,在查询单个叶类型的实体时,TPC 非常出色。 查询仅使用单个表,无需筛选。

TPC 插入和更新

TPC 在保存新实体时也表现良好,因为这样只需要将一行插入到单个表中。 TPH 也是如此。 使用 TPT 时,必须将行插入到许多表中,这样性能较低。

更新通常也是如此,尽管在这种情况下,如果所有更新的列都位于同一个表中,即使对于 TPT 也是如此,差异可能并不显著。

空间注意事项

当存在许多子类型且很多属性通常不使用时,TPT 和 TPC 使用存储比 TPH 少。 这是因为 TPH 表中的每一行必须为每个未使用的属性存储 NULL。 实际上,这几乎不是个问题,但在存储具有这些特征的大量数据时,值得考虑此问题。

提示

如果数据库系统支持它(例如 SQL Server),请考虑对很少填充的 TPH 列使用“稀疏列”。

密钥生成

选择的继承映射策略对如何生成和管理主键值产生了影响。 TPH 中的键很简单,因为每个实体实例都由单个表中的单个行表示。 可以使用任何类型的键值生成,无需其他约束。

对于 TPT 策略,表中始终有一行映射到层次结构的基类型。 此行可以使用任何类型的密钥生成,其他表的键使用外键约束链接到此表。

对于 TPC 来说,事情会变得更加复杂。 首先,必须了解 EF Core 要求层次结构中的所有实体都必须具有唯一键值,即使实体具有不同的类型也是如此。 因此,使用我们的示例模型,“狗”不能与“猫”具有相同的 ID 键值。 其次,与 TPT 不同,没有可充当键值生存且可生成的单个位置的常见表。 这意味着无法使用简单的 Identity 列。

对于支持序列的数据库,可以使用每个表的默认约束中引用的单个序列来生成键值。 这是上面所示的 TPC 表中使用的策略,其中每个表都有以下内容:

[Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence])

AnimalSequence 是由 EF Core 创建的数据库序列。 使用适用于 SQL Server 的 EF Core 数据库提供程序时,此策略默认用于 TPC 层次结构。 支持序列的其他数据库的数据库提供程序应具有类似的默认值。 使用序列的其他关键生成策略(如 Hi-Lo 模式)也可用于 TPC。

虽然标准标识列不适用于 TPC,但如果每个表都配置了适当的种子和增量,则可以使用标识列,以便为每个表生成的值永远不会冲突。 例如:

modelBuilder.Entity<Cat>().ToTable("Cats", tb => tb.Property(e => e.Id).UseIdentityColumn(1, 4));
modelBuilder.Entity<Dog>().ToTable("Dogs", tb => tb.Property(e => e.Id).UseIdentityColumn(2, 4));
modelBuilder.Entity<FarmAnimal>().ToTable("FarmAnimals", tb => tb.Property(e => e.Id).UseIdentityColumn(3, 4));
modelBuilder.Entity<Human>().ToTable("Humans", tb => tb.Property(e => e.Id).UseIdentityColumn(4, 4));

SQLite 不支持序列或标识种子/增量,因此在将 SQLite 与 TPC 策略结合使用时不支持整数键值生成。 但是,任何数据库(包括 SQLite)都支持客户端生成或全局唯一密钥(例如 GUID 密钥)。

外键约束

TPC 映射策略会创建非规范化的 SQL 架构,这是一些数据库纯粹主义者反对它的原因之一。 例如,考虑外键列 FavoriteAnimalId。 此列中的值必须与某些动物的主键值匹配。 使用 TPH 或 TPT 时,可以使用简单的 FK 约束在数据库中强制执行此操作。 例如:

CONSTRAINT [FK_Animals_Animals_FavoriteAnimalId] FOREIGN KEY ([FavoriteAnimalId]) REFERENCES [Animals] ([Id])

但是,使用 TPC 时,动物的主要键存储在该动物的具体类型的表中。 例如,猫的主键存储在 Cats.Id 列中,而狗的主键存储在 Dogs.Id 列中,等等。 这意味着无法为此关系创建 FK 约束。

实际上,只要应用程序不尝试插入无效数据,就不是问题。 例如,如果 EF Core 插入所有数据并使用导航来关联实体,则保证 FK 列将随时包含有效的 PK 值。

摘要和指南

总之,TPC 是一个很好的映射策略,当代码主要查询单个叶类型的实体时使用。 这是因为存储要求较小,并且没有可能需要索引的歧视性列。 插入和更新也有效。

尽管如此,TPH 通常适用于大多数应用程序,并且对于各种方案而言都是一个很好的默认值,因此,如果不需要 TPC,请不要添加 TPC 的复杂性。 具体而言,如果代码主要查询许多类型的实体,例如针对基类型编写查询,则倾向于使用 TPH,而不是 TPC。

仅当受外部因素约束时,才使用 TPT。

自定义反向工程模板

现在可以在从数据库反向工程 EF 模型时自定义基架代码。 首先将默认模板添加到项目:

dotnet new install Microsoft.EntityFrameworkCore.Templates
dotnet new ef-templates

然后,模板可以自定义,由 dotnet ef dbcontext scaffoldScaffold-DbContext 自动使用。

有关更多详细信息,请参阅自定义反向工程模板

提示

EF 团队在 .NET 数据社区站立的一集中演示并深入讨论了反向工程模板。 与所有社区站立剧集一样,你可以观看 YouTube 上的 T4 模板剧集

模型生成约定

EF Core 使用元数据“模型”来描述如何将应用程序的实体类型映射到基础数据库。 此模型是使用大约 60 个“约定”集构建的。 然后,可以使用映射属性(即“数据注释”)和/或 OnModelCreating 中对 DbModelBuilder API 的调用来自定义该模型

从 EF7 开始,应用程序现在可以删除或替换其中任何约定,并添加新约定。 模型构建约定是控制模型配置的强大方法,但可能很复杂且难以处理得当。 在许多情况下,可以使用现有的预约定模型配置来轻松指定属性和类型的常见配置。

通过重写 DbContext.ConfigureConventions 方法对 DbContext 使用的约定进行更改。 例如:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Remove(typeof(ForeignKeyIndexConvention));
}

提示

若要查找所有内置模型生成约定,请查找实现 IConvention 接口的每个类。

提示

此处显示的代码来自 ModelBuildingConventionsSample.cs

删除现有约定

有时,其中一个内置约定可能不适用于你的应用程序,在这种情况下,可以将其删除。

示例:不要为外键列创建索引

通常,为外键 (FK) 列创建索引是有意义的,因此有一个内置约定:ForeignKeyIndexConvention。 查看与 BlogAuthor 有关系的 Post 实体类型的模型调试视图,可以看到创建了两个索引 - 一个用于 BlogId FK,另一个用于 AuthorId FK。

  EntityType: Post
    Properties:
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      AuthorId (no field, int?) Shadow FK Index
      BlogId (no field, int) Shadow Required FK Index
    Navigations:
      Author (Author) ToPrincipal Author Inverse: Posts
      Blog (Blog) ToPrincipal Blog Inverse: Posts
    Keys:
      Id PK
    Foreign keys:
      Post {'AuthorId'} -> Author {'Id'} ToDependent: Posts ToPrincipal: Author ClientSetNull
      Post {'BlogId'} -> Blog {'Id'} ToDependent: Posts ToPrincipal: Blog Cascade
    Indexes:
      AuthorId
      BlogId

但是,索引有开销,并且按照此处的要求,可能并不总是适合为所有 FK 列创建索引。 为此,可以在生成模型时删除 ForeignKeyIndexConvention

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Remove(typeof(ForeignKeyIndexConvention));
}

现在,从 Post 模型的调试视图来看,我们发现尚未创建 FK 上的索引:

  EntityType: Post
    Properties:
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      AuthorId (no field, int?) Shadow FK
      BlogId (no field, int) Shadow Required FK
    Navigations:
      Author (Author) ToPrincipal Author Inverse: Posts
      Blog (Blog) ToPrincipal Blog Inverse: Posts
    Keys:
      Id PK
    Foreign keys:
      Post {'AuthorId'} -> Author {'Id'} ToDependent: Posts ToPrincipal: Author ClientSetNull
      Post {'BlogId'} -> Blog {'Id'} ToDependent: Posts ToPrincipal: Blog Cascade

如果需要,仍可使用 IndexAttribute 为外键列显式创建索引:

[Index("BlogId")]
public class Post
{
    // ...
}

或使用 OnModelCreating 中的配置:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>(entityTypeBuilder => entityTypeBuilder.HasIndex("BlogId"));
}

再次查看 Post 实体类型,它现在包含 BlogId 索引,但不包含 AuthorId 索引:

  EntityType: Post
    Properties:
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      AuthorId (no field, int?) Shadow FK
      BlogId (no field, int) Shadow Required FK Index
    Navigations:
      Author (Author) ToPrincipal Author Inverse: Posts
      Blog (Blog) ToPrincipal Blog Inverse: Posts
    Keys:
      Id PK
    Foreign keys:
      Post {'AuthorId'} -> Author {'Id'} ToDependent: Posts ToPrincipal: Author ClientSetNull
      Post {'BlogId'} -> Blog {'Id'} ToDependent: Posts ToPrincipal: Blog Cascade
    Indexes:
      BlogId

提示

如果模型不使用映射特性(又名数据注释)进行配置,则可以安全地删除名称以 AttributeConvention 结尾的所有约定,以加快模型生成速度。

添加新约定

删除现有约定是一个开始,但如何添加全新的模型生成约定? EF7 也支持此功能!

示例:约束歧视性属性的长度

每个层次结构一张表继承映射策略需要一个鉴别器列来指定任何给定行中表示的类型。 默认情况下,EF 对鉴别器使用未绑定的字符串列,这可确保它适用于任何歧视性长度。 但是,限制歧视性字符串的最大长度可能会提高存储和查询的效率。 我们创建一个新的约定来执行此操作。

EF Core 模型生成约定是根据在生成模型时对模型的更改触发的。 这让模型保持最新状态,因为进行了显式配置、应用映射属性和其他约定。 为了参与此目的,每个约定实现一个或多个接口,用于确定何时触发约定。 例如,每当向模型添加新实体类型时,都会触发实现 IEntityTypeAddedConvention 的约定。 同样,每当将键或外键添加到模型时,都会触发实现 IForeignKeyAddedConventionIKeyAddedConvention 的约定。

了解要实现的接口可能很棘手,因为以后可能会更改或删除对模型的配置。 例如,密钥可以通过约定创建,但稍后在显式配置其他密钥时被替换。

我们通过首次尝试实现歧视性长度约定来更具体地展示:

public class DiscriminatorLengthConvention1 : IEntityTypeBaseTypeChangedConvention
{
    public void ProcessEntityTypeBaseTypeChanged(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionEntityType? newBaseType,
        IConventionEntityType? oldBaseType,
        IConventionContext<IConventionEntityType> context)
    {
        var discriminatorProperty = entityTypeBuilder.Metadata.FindDiscriminatorProperty();
        if (discriminatorProperty != null
            && discriminatorProperty.ClrType == typeof(string))
        {
            discriminatorProperty.Builder.HasMaxLength(24);
        }
    }
}

此约定实现 IEntityTypeBaseTypeChangedConvention,这意味着每当更改实体类型的映射继承层次结构时,都会触发它。 然后,约定查找并配置层次结构的字符串鉴别器属性。

然后,通过在 ConfigureConventions 中调用 Add 来使用此约定:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Add(_ =>  new DiscriminatorLengthConvention1());
}

提示

Add 方法接受用于创建约定实例的工厂,而不是直接添加约定的实例。 这样,约定就可以使用 EF Core 内部服务提供程序中的依赖项。 由于此约定没有依赖项,因此服务提供程序参数命名为 _,表示它永远不会使用。

生成模型并查看 Post 实体类型显示,这样做是有效的,现在已将鉴别器属性配置为最大长度为 24:

 Discriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(24)

但是,如果我们现在显式配置不同的歧视性属性,会发生什么情况? 例如:

modelBuilder.Entity<Post>()
    .HasDiscriminator<string>("PostTypeDiscriminator")
    .HasValue<Post>("Post")
    .HasValue<FeaturedPost>("Featured");

查看模型的调试视图,我们发现不再配置歧视性长度!

 PostTypeDiscriminator (no field, string) Shadow Required AfterSave:Throw

这是因为我们在约定中配置的歧视性属性后来在添加自定义鉴别器时被删除。 我们可以尝试通过在约定上实现另一个接口来应对歧视性更改来解决此问题,但找出要实现的接口并不容易。

幸运的是,有一种不同的方法来解决此问题,使事情变得更加容易。 很多时候,只要最终模型正确,模型在生成时的样子就无关紧要。 此外,我们要应用的配置通常不需要触发其他约定来做出反应。 因此,我们的约定可以实现 IModelFinalizingConvention。 模型终结约定在所有其他模型生成完成后运行,因此有权访问模型的最终状态。 模型最终约定通常会循环访问整个模型,并按原样配置模型元素。 因此,在这种情况下,我们会在模型中找到每个鉴别器并对其进行配置:

public class DiscriminatorLengthConvention2 : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()
                     .Where(entityType => entityType.BaseType == null))
        {
            var discriminatorProperty = entityType.FindDiscriminatorProperty();
            if (discriminatorProperty != null
                && discriminatorProperty.ClrType == typeof(string))
            {
                discriminatorProperty.Builder.HasMaxLength(24);
            }
        }
    }
}

使用此新约定生成模型后,我们发现,即使已对其进行自定义,现在也正确配置了鉴别器长度:

PostTypeDiscriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(24)

只是为了好玩,我们更进一步,将最大长度配置为最长的歧视性值的长度。

public class DiscriminatorLengthConvention3 : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()
                     .Where(entityType => entityType.BaseType == null))
        {
            var discriminatorProperty = entityType.FindDiscriminatorProperty();
            if (discriminatorProperty != null
                && discriminatorProperty.ClrType == typeof(string))
            {
                var maxDiscriminatorValueLength =
                    entityType.GetDerivedTypesInclusive().Select(e => ((string)e.GetDiscriminatorValue()!).Length).Max();

                discriminatorProperty.Builder.HasMaxLength(maxDiscriminatorValueLength);
            }
        }
    }
}

现在,鉴别器列最大长度为 8,即“精选”的长度,这是使用中最长的歧视性值。

PostTypeDiscriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(8)

提示

你可能想知道约定是否还应为歧视性列创建索引。 GitHub 上对此进行了讨论。 简短的回答是,有时索引可能很有用,但大多数时候,它可能没有用。 因此,最好根据需要在此处创建适当的索引,而不是始终执行约定。 但是,如果不同意这一点,则可以轻松修改上述约定来创建索引。

示例:所有字符串属性的默认长度

我们看看另一个示例,其中可以使用最终约定。这次为任何字符串属性设置默认的最大长度,如 GitHub 上要求。 约定看起来与前面的示例非常相似:

public class MaxStringLengthConvention : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var property in modelBuilder.Metadata.GetEntityTypes()
                     .SelectMany(
                         entityType => entityType.GetDeclaredProperties()
                             .Where(
                                 property => property.ClrType == typeof(string))))
        {
            property.Builder.HasMaxLength(512);
        }
    }
}

此约定非常简单。 它查找模型中的每个字符串属性,并将其最大长度设置为 512。 在调试视图中查看 Post 的属性,我们看到所有字符串属性现在的最大长度为 512。

EntityType: Post
  Properties:
    Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
    AuthorId (no field, int?) Shadow FK Index
    BlogId (no field, int) Shadow Required FK Index
    Content (string) Required MaxLength(512)
    Discriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(512)
    PublishedOn (DateTime) Required
    Title (string) Required MaxLength(512)

但是,Content 属性可能允许超过 512 个字符,否则我们所有的帖子都将相当短! 这可以在不更改约定的情况下,只需显式配置此属性的最大长度,即可使用映射属性:

[MaxLength(4000)]
public string Content { get; set; }

或使用 OnModelCreating 中的代码:

modelBuilder.Entity<Post>()
    .Property(post => post.Content)
    .HasMaxLength(4000);

现在,所有属性的最大长度为 512,但显式配置了 4000 的 Content 除外:

EntityType: Post
  Properties:
    Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
    AuthorId (no field, int?) Shadow FK Index
    BlogId (no field, int) Shadow Required FK Index
    Content (string) Required MaxLength(4000)
    Discriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(512)
    PublishedOn (DateTime) Required
    Title (string) Required MaxLength(512)

那么,为什么我们的约定不会覆盖显式配置的最大长度? 答案是,EF Core 会跟踪每个配置是如何进行的。 这由 ConfigurationSource 枚举表示。 不同类型的配置包括:

  • Explicit:模型元素在 OnModelCreating 中显式配置
  • DataAnnotation:模型元素是使用 CLR 类型的映射属性(即数据注释)配置的
  • Convention:模型元素是由模型构建约定配置的

约定永远不会替代标记为 DataAnnotationExplicit 的配置。 这是通过使用“约定生成器”来实现的,例如,从 Builder 属性获取的 IConventionPropertyBuilder。 例如:

property.Builder.HasMaxLength(512);

在约定生成器上调用 HasMaxLength 将仅设置映射属性或 OnModelCreating 中尚未配置的最大长度

此类生成器方法还有第二个参数:fromDataAnnotation。 如果约定代表映射属性进行配置,则将其设置为 true。 例如:

property.Builder.HasMaxLength(512, fromDataAnnotation: true);

这会将 ConfigurationSource 设置为 DataAnnotation,这意味着现在可以通过 OnModelCreating 上的显式映射来替代该值,但不能通过非映射属性约定来替代该值。

最后,在离开此示例之前,如果同时使用 MaxStringLengthConventionDiscriminatorLengthConvention3 会发生什么情况? 答案是,这取决于添加的顺序,因为模型最终约定按照添加的顺序运行。 因此,如果最后添加 MaxStringLengthConvention,则它将最后运行,并将鉴别器属性的最大长度设置为 512。 因此,在这种情况下,最好最后添加 DiscriminatorLengthConvention3个,以便它可以替代仅歧视性属性的默认最大长度,同时将所有其他字符串属性保留为 512。

替换现有约定

有时,我们不想完全删除现有约定,而是想将其替换为一种基本相同操作但行为已更改的约定。 这很有用,因为现有约定已经实现了它所需的接口,以便适当触发。

示例:选择加入属性映射

EF Core 按约定映射所有公共读写属性。 对于定义实体类型的方式,此 可能不适合。 若要更改此项,我们可以将 PropertyDiscoveryConvention 替换为不映射任何属性的自己的实现,除非它在 OnModelCreating 中显式映射,或者用名为 Persist 的新属性进行标记:

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public sealed class PersistAttribute : Attribute
{
}

下面是新的约定:

public class AttributeBasedPropertyDiscoveryConvention : PropertyDiscoveryConvention
{
    public AttributeBasedPropertyDiscoveryConvention(ProviderConventionSetBuilderDependencies dependencies)
        : base(dependencies)
    {
    }

    public override void ProcessEntityTypeAdded(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionContext<IConventionEntityTypeBuilder> context)
        => Process(entityTypeBuilder);

    public override void ProcessEntityTypeBaseTypeChanged(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionEntityType? newBaseType,
        IConventionEntityType? oldBaseType,
        IConventionContext<IConventionEntityType> context)
    {
        if ((newBaseType == null
             || oldBaseType != null)
            && entityTypeBuilder.Metadata.BaseType == newBaseType)
        {
            Process(entityTypeBuilder);
        }
    }

    private void Process(IConventionEntityTypeBuilder entityTypeBuilder)
    {
        foreach (var memberInfo in GetRuntimeMembers())
        {
            if (Attribute.IsDefined(memberInfo, typeof(PersistAttribute), inherit: true))
            {
                entityTypeBuilder.Property(memberInfo);
            }
            else if (memberInfo is PropertyInfo propertyInfo
                     && Dependencies.TypeMappingSource.FindMapping(propertyInfo) != null)
            {
                entityTypeBuilder.Ignore(propertyInfo.Name);
            }
        }

        IEnumerable<MemberInfo> GetRuntimeMembers()
        {
            var clrType = entityTypeBuilder.Metadata.ClrType;

            foreach (var property in clrType.GetRuntimeProperties()
                         .Where(p => p.GetMethod != null && !p.GetMethod.IsStatic))
            {
                yield return property;
            }

            foreach (var property in clrType.GetRuntimeFields())
            {
                yield return property;
            }
        }
    }
}

提示

替换内置约定时,新的约定实现应继承自现有约定类。 请注意,某些约定具有关系或提供程序特定的实现,在这种情况下,新的约定实现应继承自正在使用的数据库提供程序最具体的现有约定类。

然后,使用 ConfigureConventions 中的 Replace 方法注册该约定:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Replace<PropertyDiscoveryConvention>(
        serviceProvider => new AttributeBasedPropertyDiscoveryConvention(
            serviceProvider.GetRequiredService<ProviderConventionSetBuilderDependencies>()));
}

提示

在这种情况下,现有约定具有依赖项,由 ProviderConventionSetBuilderDependencies 依赖项对象表示。 这些项是使用 GetRequiredService 从内部服务提供商获取的,并传递给约定构造函数。

此约定的工作原理是从给定实体类型获取所有可读属性和字段。 如果成员具有 [Persist] 特性,则通过调用来映射该成员:

entityTypeBuilder.Property(memberInfo);

另一方面,如果成员是本来会映射的属性,则使用以下命令从模型中排除该成员:

entityTypeBuilder.Ignore(propertyInfo.Name);

请注意,此约定允许映射字段(除了属性),只要这些字段标记为 [Persist]。 这意味着我们可以在模型中将专用字段用作隐藏密钥。

例如,请考虑以下实体类型:

public class LaundryBasket
{
    [Persist]
    [Key]
    private readonly int _id;

    [Persist]
    public int TenantId { get; init; }

    public bool IsClean { get; set; }

    public List<Garment> Garments { get; } = new();
}

public class Garment
{
    public Garment(string name, string color)
    {
        Name = name;
        Color = color;
    }

    [Persist]
    [Key]
    private readonly int _id;

    [Persist]
    public int TenantId { get; init; }

    [Persist]
    public string Name { get; }

    [Persist]
    public string Color { get; }

    public bool IsClean { get; set; }

    public LaundryBasket? Basket { get; set; }
}

基于这些实体类型生成的模型为:

Model:
  EntityType: Garment
    Properties:
      _id (_id, int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      Basket_id (no field, int?) Shadow FK Index
      Color (string) Required
      Name (string) Required
      TenantId (int) Required
    Navigations:
      Basket (LaundryBasket) ToPrincipal LaundryBasket Inverse: Garments
    Keys:
      _id PK
    Foreign keys:
      Garment {'Basket_id'} -> LaundryBasket {'_id'} ToDependent: Garments ToPrincipal: Basket ClientSetNull
    Indexes:
      Basket_id
  EntityType: LaundryBasket
    Properties:
      _id (_id, int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      TenantId (int) Required
    Navigations:
      Garments (List<Garment>) Collection ToDependent Garment Inverse: Basket
    Keys:
      _id PK

请注意,通常 IsClean 已被映射,但由于它没有标记为 [Persist](大概因为清洁不是洗衣的持久性属性),它现在被视为未映射的属性。

提示

无法将此约定作为模型最终约定实现,因为映射属性会触发许多其他约定来运行以进一步配置映射的属性。

存储过程映射

默认情况下,EF Core 会生成直接处理表或可更新视图的插入、更新和删除命令。 EF7 引入了将这些命令映射到存储过程的支持。

提示

EF Core 始终支持通过存储过程进行查询。 EF7 中的新支持显式介绍如何使用存储过程进行插入、更新和删除。

重要

对存储过程映射的支持并不意味着建议使用存储过程。

存储过程使用 InsertUsingStoredProcedureUpdateUsingStoredProcedureDeleteUsingStoredProcedureOnModelCreating 中映射。 例如,若要映射 Person 实体类型的存储过程:

modelBuilder.Entity<Person>()
    .InsertUsingStoredProcedure(
        "People_Insert",
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasParameter(a => a.Name);
            storedProcedureBuilder.HasResultColumn(a => a.Id);
        })
    .UpdateUsingStoredProcedure(
        "People_Update",
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Id);
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Name);
            storedProcedureBuilder.HasParameter(person => person.Name);
            storedProcedureBuilder.HasRowsAffectedResultColumn();
        })
    .DeleteUsingStoredProcedure(
        "People_Delete",
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Id);
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Name);
            storedProcedureBuilder.HasRowsAffectedResultColumn();
        });

此配置在使用 SQL Server 时映射到以下存储过程:

关于插入

CREATE PROCEDURE [dbo].[People_Insert]
    @Name [nvarchar](max)
AS
BEGIN
      INSERT INTO [People] ([Name])
      OUTPUT INSERTED.[Id]
      VALUES (@Name);
END

关于更新

CREATE PROCEDURE [dbo].[People_Update]
    @Id [int],
    @Name_Original [nvarchar](max),
    @Name [nvarchar](max)
AS
BEGIN
    UPDATE [People] SET [Name] = @Name
    WHERE [Id] = @Id AND [Name] = @Name_Original
    SELECT @@ROWCOUNT
END

关于删除

CREATE PROCEDURE [dbo].[People_Delete]
    @Id [int],
    @Name_Original [nvarchar](max)
AS
BEGIN
    DELETE FROM [People]
    OUTPUT 1
    WHERE [Id] = @Id AND [Name] = @Name_Original;
END

提示

存储过程不需要用于模型中的每一种类型,也不需要用于给定类型上的所有操作。 例如,如果只为给定类型指定了 DeleteUsingStoredProcedure,则 EF Core 将为插入和更新操作生成正常 SQL,并且仅使用存储过程进行删除。

传递给每个方法的第一个参数是存储过程名称。 可以省略这种情况,在这种情况下,EF Core 将使用追加有“_Insert”、“_Update”或“_Delete”的表名称。 因此,在上面的示例中,由于表被称为“People”,因此可以删除存储过程名称,且功能没有变化。

第二个参数是一个生成器,用于配置存储过程的输入和输出,包括参数、返回值和结果列。

参数

参数必须按照存储过程定义中显示的顺序添加到生成器中。

备注

可以命名参数,但 EF Core 始终使用位置参数而不是命名参数调用存储过程。 如果您对按名称调用感兴趣,请投票允许配置 sproc 映射,以便使用参数名称进行调用

每个参数生成器方法的第一个参数指定参数绑定到的模型中的属性。 这可以是 lambda 表达式:

storedProcedureBuilder.HasParameter(a => a.Name);

或字符串,在映射阴影属性时特别有用:

storedProcedureBuilder.HasParameter("Name");

默认情况下,参数配置为“输入”。 可以使用嵌套生成器配置“输出”或“输入/输出”参数。 例如:

storedProcedureBuilder.HasParameter(
    document => document.RetrievedOn, 
    parameterBuilder => parameterBuilder.IsOutput());

对于不同类型的参数,有三种不同的生成器方法:

  • HasParameter 指定绑定到给定属性的当前值的普通参数。
  • HasOriginalValueParameter 指定绑定到给定属性的原始值的参数。 原始值是属性从数据库查询时具有的值(如果已知)。 如果此值未知,则改用当前值。 原始值参数对于并发令牌很有用。
  • HasRowsAffectedParameter 指定用于返回受存储过程影响的行数的参数。

提示

原始值参数必须用于“更新”和“删除”存储过程中的键值。 这可确保在支持可变键值的 EF Core 的未来版本中更新正确的行。

返回值

EF Core 支持三种从存储过程返回值的机制:

  • 输出参数,如上所示。
  • 使用 HasResultColumn 生成器方法指定的结果列。
  • 返回值仅限于返回受影响的行数,并使用 HasRowsAffectedReturnValue 生成器方法指定。

从存储过程返回的值通常用于生成、默认或计算值,例如从 Identity 键或计算列返回的值。 例如,以下配置指定四个结果列:

entityTypeBuilder.InsertUsingStoredProcedure(
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasParameter(document => document.Title);
            storedProcedureBuilder.HasResultColumn(document => document.Id);
            storedProcedureBuilder.HasResultColumn(document => document.FirstRecordedOn);
            storedProcedureBuilder.HasResultColumn(document => document.RetrievedOn);
            storedProcedureBuilder.HasResultColumn(document => document.RowVersion);
        });

这些用于返回:

  • Id 属性生成的键值。
  • 数据库为 FirstRecordedOn 属性生成的默认值。
  • 数据库为 RetrievedOn 属性生成的计算值。
  • RowVersion 属性自动生成的 rowversion 并发令牌。

此配置在使用 SQL Server 时映射到以下存储过程:

CREATE PROCEDURE [dbo].[Documents_Insert]
    @Title [nvarchar](max)
AS
BEGIN
    INSERT INTO [Documents] ([Title])
    OUTPUT INSERTED.[Id], INSERTED.[FirstRecordedOn], INSERTED.[RetrievedOn], INSERTED.[RowVersion]
    VALUES (@Title);
END

开放式并发

乐观并发与存储过程的工作方式与存储过程的工作方式相同。 存储过程应:

  • WHERE 子句中使用并发令牌来确保仅当行具有有效令牌时才会更新该行。 用于并发令牌的值通常不必是并发令牌属性的原始值。
  • 返回受影响的行数,以便 EF Core 可以将这与受影响的行数进行比较,并在值不匹配时引发 DbUpdateConcurrencyException

例如,以下 SQL Server 存储过程使用 rowversion 自动并发令牌:

CREATE PROCEDURE [dbo].[Documents_Update]
    @Id [int],
    @RowVersion_Original [rowversion],
    @Title [nvarchar](max),
    @RowVersion [rowversion] OUT
AS
BEGIN
    DECLARE @TempTable table ([RowVersion] varbinary(8));
    UPDATE [Documents] SET
        [Title] = @Title
    OUTPUT INSERTED.[RowVersion] INTO @TempTable
    WHERE [Id] = @Id AND [RowVersion] = @RowVersion_Original
    SELECT @@ROWCOUNT;
    SELECT @RowVersion = [RowVersion] FROM @TempTable;
END

这在 EF Core 中使用以下配置:

.UpdateUsingStoredProcedure(
    storedProcedureBuilder =>
    {
        storedProcedureBuilder.HasOriginalValueParameter(document => document.Id);
        storedProcedureBuilder.HasOriginalValueParameter(document => document.RowVersion);
        storedProcedureBuilder.HasParameter(document => document.Title);
        storedProcedureBuilder.HasParameter(document => document.RowVersion, parameterBuilder => parameterBuilder.IsOutput());
        storedProcedureBuilder.HasRowsAffectedResultColumn();
    });

请注意:

  • 使用 RowVersion 并发令牌的原始值。
  • 存储过程使用 WHERE 子句来确保仅当 RowVersion 原始值匹配时才会更新该行。
  • RowVersion 的新生成值将插入临时表中。
  • 返回受影响的行数(@@ROWCOUNT)和生成的 RowVersion 值。

将继承层次结构映射到存储过程

EF Core 要求存储过程遵循层次结构中类型的表布局。 这表示:

  • 使用 TPH 映射的层次结构必须具有针对单个映射表的单个插入、更新和/或删除存储过程。 插入和更新存储过程必须具有歧视性值的参数。
  • 使用 TPT 映射的层次结构必须具有每种类型的插入、更新和/或删除存储过程,包括抽象类型。 EF Core 将根据需要进行多次调用,以更新、插入和删除所有表中的行。
  • 使用 TPC 映射的层次结构必须为每个具体类型具有插入、更新和/或删除存储过程,但不包括抽象类型。

备注

如果每个具体类型使用单个存储过程而不考虑映射策略是你感兴趣的内容,请投票支持无论继承映射策略,都使用每个具体类型的单个 sproc

将拥有的类型映射到存储过程

在嵌套拥有的类型生成器中完成对自有类型的存储过程的配置。 例如:

modelBuilder.Entity<Person>(
    entityTypeBuilder =>
    {
        entityTypeBuilder.OwnsOne(
            author => author.Contact,
            ownedNavigationBuilder =>
            {
                ownedNavigationBuilder.ToTable("Contacts");
                ownedNavigationBuilder
                    .InsertUsingStoredProcedure(
                        storedProcedureBuilder =>
                        {
                            storedProcedureBuilder.HasParameter("PersonId");
                            storedProcedureBuilder.HasParameter(contactDetails => contactDetails.Phone);
                        })
                    .UpdateUsingStoredProcedure(
                        storedProcedureBuilder =>
                        {
                            storedProcedureBuilder.HasOriginalValueParameter("PersonId");
                            storedProcedureBuilder.HasParameter(contactDetails => contactDetails.Phone);
                            storedProcedureBuilder.HasRowsAffectedResultColumn();
                        })
                    .DeleteUsingStoredProcedure(
                        storedProcedureBuilder =>
                        {
                            storedProcedureBuilder.HasOriginalValueParameter("PersonId");
                            storedProcedureBuilder.HasRowsAffectedResultColumn();
            });
    });

备注

当前用于插入、更新和删除的存储过程仅支持拥有的类型必须映射到单独的表。 也就是说,拥有的类型不能由所有者表中的列表示。 如果这是你想要删除的限制,请投票向 CUD sproc 映射添加“表”拆分支持

将多对多联接实体映射到存储过程

可将存储过程多对多联接实体的配置作为多对多配置的一部分执行。 例如:

modelBuilder.Entity<Book>(
    entityTypeBuilder =>
    {
        entityTypeBuilder
            .HasMany(document => document.Authors)
            .WithMany(author => author.PublishedWorks)
            .UsingEntity<Dictionary<string, object>>(
                "BookPerson",
                builder => builder.HasOne<Person>().WithMany().OnDelete(DeleteBehavior.Cascade),
                builder => builder.HasOne<Book>().WithMany().OnDelete(DeleteBehavior.ClientCascade),
                joinTypeBuilder =>
                {
                    joinTypeBuilder
                        .InsertUsingStoredProcedure(
                            storedProcedureBuilder =>
                            {
                                storedProcedureBuilder.HasParameter("AuthorsId");
                                storedProcedureBuilder.HasParameter("PublishedWorksId");
                            })
                        .DeleteUsingStoredProcedure(
                            storedProcedureBuilder =>
                            {
                                storedProcedureBuilder.HasOriginalValueParameter("AuthorsId");
                                storedProcedureBuilder.HasOriginalValueParameter("PublishedWorksId");
                                storedProcedureBuilder.HasRowsAffectedResultColumn();
                            });
                });
    });

新增和改进的拦截器和事件

EF Core 侦听器启用 EF Core 操作的拦截、修改和/或抑制。 EF Core 还包括传统 .NET 事件日志记录

EF7 包括以下拦截器的增强功能:

此外,EF7 还包括以下新的传统 .NET 事件:

以下部分显示了使用这些新的拦截功能的一些示例。

有关创建实体的简单操作

提示

此处显示的代码来自 SimpleMaterializationSample.cs

IMaterializationInterceptor 支持在创建实体实例前后以及初始化该实例的属性前后拦截。 拦截器可以更改或替换每个点的实体实例。 这允许:

  • 设置验证、计算值或标记所需的未映射属性或调用方法。
  • 使用工厂创建实例。
  • 创建与 EF 通常会创建的不同的实体实例,例如缓存中的实例或代理类型的实例。
  • 将服务注入实体实例。

例如,假设我们要跟踪从数据库中检索实体的时间,或许可以向用户显示它来编辑数据。 为此,我们首先定义一个接口:

public interface IHasRetrieved
{
    DateTime Retrieved { get; set; }
}

使用接口与拦截器很常见,因为它允许同一拦截器处理许多不同的实体类型。 例如:

public class Customer : IHasRetrieved
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string? PhoneNumber { get; set; }

    [NotMapped]
    public DateTime Retrieved { get; set; }
}

请注意,[NotMapped] 属性用于表示此属性仅在处理实体时使用,不应保存到数据库。

然后,拦截必须从 IMaterializationInterceptor 实现适当的方法,并设置检索的时间:

public class SetRetrievedInterceptor : IMaterializationInterceptor
{
    public object InitializedInstance(MaterializationInterceptionData materializationData, object instance)
    {
        if (instance is IHasRetrieved hasRetrieved)
        {
            hasRetrieved.Retrieved = DateTime.UtcNow;
        }

        return instance;
    }
}

配置 DbContext 时注册此拦截器的实例:

public class CustomerContext : DbContext
{
    private static readonly SetRetrievedInterceptor _setRetrievedInterceptor = new();

    public DbSet<Customer> Customers
        => Set<Customer>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .AddInterceptors(_setRetrievedInterceptor)
            .UseSqlite("Data Source = customers.db");
}

提示

此拦截器是无状态的,这是常见的,因此在所有 DbContext 实例之间创建和共享单个实例。

现在,每当从数据库查询 Customer 时,将自动设置 Retrieved 属性。 例如:

await using (var context = new CustomerContext())
{
    var customer = await context.Customers.SingleAsync(e => e.Name == "Alice");
    Console.WriteLine($"Customer '{customer.Name}' was retrieved at '{customer.Retrieved.ToLocalTime()}'");
}

生成输出:

Customer 'Alice' was retrieved at '9/22/2022 5:25:54 PM'

将服务注入实体

提示

此处显示的代码来自 InjectLoggerSample.cs

EF Core 已内置支持将某些特殊服务注入上下文实例;例如,请参阅延迟加载而不使用代理,这可以通过注入 ILazyLoader 服务来工作。

可以使用 IMaterializationInterceptor 将此通用化为任何服务。 以下示例演示如何将 ILogger 注入实体,以便它们可以执行自己的日志记录。

备注

将服务注入实体会将这些实体类型与注入的服务相耦合,有些人认为这是一种反模式。

与之前一样,接口用于定义可以执行的操作。

public interface IHasLogger
{
    ILogger? Logger { get; set; }
}

将记录的实体类型必须实现此接口。 例如:

public class Customer : IHasLogger
{
    private string? _phoneNumber;

    public int Id { get; set; }
    public string Name { get; set; } = null!;

    public string? PhoneNumber
    {
        get => _phoneNumber;
        set
        {
            Logger?.LogInformation(1, $"Updating phone number for '{Name}' from '{_phoneNumber}' to '{value}'.");

            _phoneNumber = value;
        }
    }

    [NotMapped]
    public ILogger? Logger { get; set; }
}

这一次,拦截器必须实现 IMaterializationInterceptor.InitializedInstance,在创建每个实体实例并初始化其属性值后调用。 拦截器从上下文中获取 ILogger,并使用它初始化 IHasLogger.Logger

public class LoggerInjectionInterceptor : IMaterializationInterceptor
{
    private ILogger? _logger;

    public object InitializedInstance(MaterializationInterceptionData materializationData, object instance)
    {
        if (instance is IHasLogger hasLogger)
        {
            _logger ??= materializationData.Context.GetService<ILoggerFactory>().CreateLogger("CustomersLogger");
            hasLogger.Logger = _logger;
        }

        return instance;
    }
}

这一次,拦截器的新实例用于每个 DbContext 实例,因为获取 ILogger 可以更改每个 DbContext 实例,并且 ILogger 缓存在拦截器上:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder.AddInterceptors(new LoggerInjectionInterceptor());

现在,每当 Customer.PhoneNumber 发生更改时,此更改将记录到应用程序的日志中。 例如:

info: CustomersLogger[1]
      Updating phone number for 'Alice' from '+1 515 555 0123' to '+1 515 555 0125'.

LINQ 表达式树拦截

提示

此处显示的代码来自 QueryInterceptionSample.cs

EF Core 利用 .NET LINQ 查询。 这通常涉及使用 C#、VB 或 F# 编译器生成表达式树,然后 EF Core 将表达式树转换为相应的 SQL。 例如,请考虑返回客户页面的方法:

Task<List<Customer>> GetPageOfCustomers(string sortProperty, int page)
{
    using var context = new CustomerContext();

    return context.Customers
        .OrderBy(e => EF.Property<object>(e, sortProperty))
        .Skip(page * 20).Take(20).ToListAsync();
}

提示

此查询使用 EF.Property 方法指定要排序的属性。 这允许应用程序动态传入属性名称,允许按实体类型的任何属性进行排序。 请注意,按非索引列排序可能很慢。

只要用于排序的属性始终返回稳定的排序,此操作就会正常工作。 但情况可能并不总是如此。 例如,上述 LINQ 查询在按 Customer.City 排序时在 SQLite 上生成以下内容:

SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber"
FROM "Customers" AS "c"
ORDER BY "c"."City"
LIMIT @__p_1 OFFSET @__p_0

如果有多个客户具有相同 City,则此查询的排序不稳定。 这可能会导致用户对数据进行分页时丢失或重复结果。

解决此问题的一种常见方法是按主键执行辅助排序。 但是,EF7 允许拦截查询表达式树,可动态添加辅助排序,而不是手动将其添加到每个查询。 为便于执行此操作,我们将再次对具有整数主键的任何实体使用接口:

public interface IHasIntKey
{
    int Id { get; }
}

此接口由感兴趣的实体类型实现:

public class Customer : IHasIntKey
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string? City { get; set; }
    public string? PhoneNumber { get; set; }
}

然后,我们需要实现 IQueryExpressionInterceptor 的拦截器

public class KeyOrderingExpressionInterceptor : IQueryExpressionInterceptor
{
    public Expression QueryCompilationStarting(Expression queryExpression, QueryExpressionEventData eventData)
        => new KeyOrderingExpressionVisitor().Visit(queryExpression);

    private class KeyOrderingExpressionVisitor : ExpressionVisitor
    {
        private static readonly MethodInfo ThenByMethod
            = typeof(Queryable).GetMethods()
                .Single(m => m.Name == nameof(Queryable.ThenBy) && m.GetParameters().Length == 2);

        protected override Expression VisitMethodCall(MethodCallExpression? methodCallExpression)
        {
            var methodInfo = methodCallExpression!.Method;
            if (methodInfo.DeclaringType == typeof(Queryable)
                && methodInfo.Name == nameof(Queryable.OrderBy)
                && methodInfo.GetParameters().Length == 2)
            {
                var sourceType = methodCallExpression.Type.GetGenericArguments()[0];
                if (typeof(IHasIntKey).IsAssignableFrom(sourceType))
                {
                    var lambdaExpression = (LambdaExpression)((UnaryExpression)methodCallExpression.Arguments[1]).Operand;
                    var entityParameterExpression = lambdaExpression.Parameters[0];

                    return Expression.Call(
                        ThenByMethod.MakeGenericMethod(
                            sourceType,
                            typeof(int)),
                        base.VisitMethodCall(methodCallExpression),
                        Expression.Lambda(
                            typeof(Func<,>).MakeGenericType(entityParameterExpression.Type, typeof(int)),
                            Expression.Property(entityParameterExpression, nameof(IHasIntKey.Id)),
                            entityParameterExpression));
                }
            }

            return base.VisitMethodCall(methodCallExpression);
        }
    }
}

这看起来可能相当复杂,而且确实如此! 使用表达式树通常不容易。 我们看看发生了什么:

  • 从根本上讲,拦截器封装 ExpressionVisitor。 访问者将替代 VisitMethodCall,每当查询表达式树中存在对方法的调用时,都会调用此方法。

  • 访问者检查这是否是对我们感兴趣的 OrderBy 方法的调用。

  • 如果是,则访问者会进一步检查泛型方法调用是否为实现 IHasIntKey 接口的类型。

  • 此时我们知道方法调用的格式为 OrderBy(e => ...)。 我们从此调用中提取 lambda 表达式,并获取该表达式中使用的参数,即 e

  • 我们现在使用 Expression.Call 生成器方法生成新的 MethodCallExpression。 在本例中,要调用的方法为 ThenBy(e => e.Id)。 我们使用上面提取的参数和对 IHasIntKey 接口 Id 属性的属性访问来生成此属性。

  • 此调用的输入是原始 OrderBy(e => ...),因此最终结果是 OrderBy(e => ...).ThenBy(e => e.Id) 的表达式。

  • 此修改后的表达式从访问者返回,这意味着 LINQ 查询现已经过适当修改,以包含 ThenBy 调用。

  • EF Core 继续将此查询表达式编译为要使用的数据库的适当 SQL。

此拦截器的注册方式与第一个示例的注册方式相同。 执行 GetPageOfCustomers 现在生成以下 SQL:

SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber"
FROM "Customers" AS "c"
ORDER BY "c"."City", "c"."Id"
LIMIT @__p_1 OFFSET @__p_0

现在,即使有多个客户具有相同的 City,这也会始终产生稳定的排序。

呼! 为了对查询进行简单的更改,需要很多代码。 更糟的是,它甚至可能不适用于所有查询。 编写一个表达式来识别应识别的所有查询形状,并且不识别不应识别的任何查询形状,这件事的难度非常高。 例如,如果在子查询中完成排序,则此操作可能不起作用。

这让我们到了关于拦截器的一个关键点,总是问自己是否有更简单的方式做你想要的。 拦截器很强大,但很容易出错。 它们就像说的那样,很容易让你搬起石头砸自己的脚。

例如,假设我们改为更改了 GetPageOfCustomers 方法,如下所示:

Task<List<Customer>> GetPageOfCustomers2(string sortProperty, int page)
{
    using var context = new CustomerContext();

    return context.Customers
        .OrderBy(e => EF.Property<object>(e, sortProperty))
        .ThenBy(e => e.Id)
        .Skip(page * 20).Take(20).ToListAsync();
}

在这种情况下,ThenBy 只是添加到查询中。 是的,它可能需要单独对每个查询执行,但很简单,易于理解,并且将始终正常工作。

乐观并发拦截

提示

此处显示的代码来自 OptimisticConcurrencyInterceptionSample.cs

EF Core 通过检查实际受更新或删除影响的行数是否与预期受影响的行数相同,支持乐观并发模式。 这通常与并发令牌耦合,也就是说,仅当行自读取预期值以来尚未更新时,一个列值才会匹配其预期值。

EF 通过引发 DbUpdateConcurrencyException 来发出违反乐观并发的信号。 在 EF7 中,ISaveChangesInterceptor 具有在引发 DbUpdateConcurrencyException 之前调用的新方法 ThrowingConcurrencyExceptionThrowingConcurrencyExceptionAsync。 这些拦截点允许取消异常,可能与异步数据库更改结合使用来解决冲突。

例如,如果两个请求几乎同时尝试删除同一实体,则第二个删除可能会失败,因为数据库中的行不再存在。 这可能没问题,最终结果是实体已被删除。 以下拦截器演示了如何完成此操作:

public class SuppressDeleteConcurrencyInterceptor : ISaveChangesInterceptor
{
    public InterceptionResult ThrowingConcurrencyException(
        ConcurrencyExceptionEventData eventData,
        InterceptionResult result)
    {
        if (eventData.Entries.All(e => e.State == EntityState.Deleted))
        {
            Console.WriteLine("Suppressing Concurrency violation for command:");
            Console.WriteLine(((RelationalConcurrencyExceptionEventData)eventData).Command.CommandText);

            return InterceptionResult.Suppress();
        }

        return result;
    }

    public ValueTask<InterceptionResult> ThrowingConcurrencyExceptionAsync(
        ConcurrencyExceptionEventData eventData,
        InterceptionResult result,
        CancellationToken cancellationToken = default)
        => new(ThrowingConcurrencyException(eventData, result));
}

关于此拦截器,有几个值得注意的事项:

  • 同时实现同步和异步拦截方法。 如果应用程序可以调用 SaveChangesSaveChangesAsync,这一点非常重要。 但是,如果所有应用程序代码都是异步的,则只需实现 ThrowingConcurrencyExceptionAsync。 同样,如果应用程序从未使用异步数据库方法,则只需实现 ThrowingConcurrencyException。 对于具有同步和异步方法的所有拦截器,这通常都是如此。 (可能值得实现应用程序不用于引发的方法,以防某些同步/异步代码出现。)
  • 拦截器有权访问要保存的实体的 EntityEntry 对象。 在这种情况下,用于检查删除操作是否发生并发冲突。
  • 如果应用程序使用的是关系数据库提供程序,则可以将 ConcurrencyExceptionEventData 对象强制转换为 RelationalConcurrencyExceptionEventData 对象。 这提供了有关正在执行的数据库操作的其他特定于关系的信息。 在这种情况下,关系命令文本将打印到控制台。
  • 返回 InterceptionResult.Suppress() 告知 EF Core 取消它即将采取的操作,在这种情况下,将引发 DbUpdateConcurrencyException。 这种更改 EF Core 行为的能力,而不仅仅是观察 EF Core 正在执行的操作,是拦截器最强大的功能之一。

连接字符串的延迟初始化

提示

此处显示的代码来自 LazyConnectionStringSample.cs

连接字符串通常是从配置文件读取的静态资产。 配置 DbContext 时,可以轻松将这些项传递给 UseSqlServer 或类似项。 但是,有时连接字符串可能会因每个上下文实例而更改。 例如,多租户系统中的每个租户可能有不同的连接字符串。

通过改进 IDbConnectionInterceptor,EF7 可以更轻松地处理动态连接和连接字符串。 这首先能够配置 DbContext,而无需任何连接字符串。 例如:

services.AddDbContext<CustomerContext>(
    b => b.UseSqlServer());

然后,可以实现其中一个 IDbConnectionInterceptor 方法来配置连接,然后再使用它。 ConnectionOpeningAsync 是一个不错的选择,因为它可以执行异步操作来获取连接字符串、查找访问令牌等。 例如,假设一个范围限定为了解当前租户的当前请求的服务:

services.AddScoped<ITenantConnectionStringFactory, TestTenantConnectionStringFactory>();

警告

每次需要对连接字符串、访问令牌或类似项执行异步查找时,可能会非常慢。 请考虑缓存这些内容,并仅定期刷新缓存的字符串或令牌。 例如,访问令牌通常可在需要刷新之前长时间使用。

这可以使用构造函数注入将每个 DbContext 实例注入:

public class CustomerContext : DbContext
{
    private readonly ITenantConnectionStringFactory _connectionStringFactory;

    public CustomerContext(
        DbContextOptions<CustomerContext> options,
        ITenantConnectionStringFactory connectionStringFactory)
        : base(options)
    {
        _connectionStringFactory = connectionStringFactory;
    }

    // ...
}

然后,在为上下文构造拦截器实现时使用此服务:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder.AddInterceptors(
        new ConnectionStringInitializationInterceptor(_connectionStringFactory));

最后,拦截器使用此服务异步获取连接字符串,并在第一次使用连接时对其进行设置:

public class ConnectionStringInitializationInterceptor : DbConnectionInterceptor
{
    private readonly IClientConnectionStringFactory _connectionStringFactory;

    public ConnectionStringInitializationInterceptor(IClientConnectionStringFactory connectionStringFactory)
    {
        _connectionStringFactory = connectionStringFactory;
    }

    public override InterceptionResult ConnectionOpening(
        DbConnection connection,
        ConnectionEventData eventData,
        InterceptionResult result)
        => throw new NotSupportedException("Synchronous connections not supported.");

    public override async ValueTask<InterceptionResult> ConnectionOpeningAsync(
        DbConnection connection, ConnectionEventData eventData, InterceptionResult result,
        CancellationToken cancellationToken = new())
    {
        if (string.IsNullOrEmpty(connection.ConnectionString))
        {
            connection.ConnectionString = (await _connectionStringFactory.GetConnectionStringAsync(cancellationToken));
        }

        return result;
    }
}

备注

仅在第一次使用连接时获取连接字符串。 之后,将使用存储在 DbConnection 上的连接字符串,而无需查找新的连接字符串。

提示

此拦截器替代要引发的非异步 ConnectionOpening 方法,因为必须从异步代码路径调用用于获取连接字符串的服务。

记录 SQL Server 查询统计信息

提示

此处显示的代码来自 QueryStatisticsLoggerSample.cs

最后,我们创建两个拦截器,协同工作,将 SQL Server 查询统计信息发送到应用程序日志。 若要生成统计信息,需要一个 IDbCommandInterceptor 来执行两项操作。

首先,拦截器将添加带有 SET STATISTICS IO ON 的命令的前缀,该命令指示 SQL Server 在使用结果集后将统计信息发送到客户端:

public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
    DbCommand command,
    CommandEventData eventData,
    InterceptionResult<DbDataReader> result,
    CancellationToken cancellationToken = default)
{
    command.CommandText = "SET STATISTICS IO ON;" + Environment.NewLine + command.CommandText;

    return new(result);
}

其次,拦截器将实现新的 DataReaderClosingAsync 方法,该方法在 DbDataReader 完成使用结果后调用,但在关闭之前。 当 SQL Server 发送统计信息时,它会将它们放在读取器上的第二个结果中,因此此时拦截器会通过调用填充到连接的统计信息 NextResultAsync 来读取该结果。

public override async ValueTask<InterceptionResult> DataReaderClosingAsync(
    DbCommand command,
    DataReaderClosingEventData eventData,
    InterceptionResult result)
{
    await eventData.DataReader.NextResultAsync();

    return result;
}

需要第二个拦截器才能从连接获取统计信息,并将其写出到应用程序的记录器。 为此,我们将使用 IDbConnectionInterceptor,实现新的 ConnectionCreated 方法。 在 EF Core 创建连接后立即调用 ConnectionCreated,因此可用于执行该连接的其他配置。 在这种情况下,拦截器获取 ILogger,然后挂钩到 SqlConnection.InfoMessage 事件以记录消息。

public override DbConnection ConnectionCreated(ConnectionCreatedEventData eventData, DbConnection result)
{
    var logger = eventData.Context!.GetService<ILoggerFactory>().CreateLogger("InfoMessageLogger");
    ((SqlConnection)eventData.Connection).InfoMessage += (_, args) =>
    {
        logger.LogInformation(1, args.Message);
    };
    return result;
}

重要

仅当 EF Core 创建 DbConnection 时,才会调用 ConnectionCreatingConnectionCreated 方法。 如果应用程序创建 DbConnection 并将其传递给 EF Core,则不会调用它们。

运行使用这些拦截器的一些代码在日志中显示 SQL Server 查询统计信息:

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (4ms) [Parameters=[@p0='?' (Size = 4000), @p1='?' (Size = 4000), @p2='?' (Size = 4000), @p3='?' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET STATISTICS IO ON;
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      MERGE [Customers] USING (
      VALUES (@p0, @p1, 0),
      (@p2, @p3, 1)) AS i ([Name], [PhoneNumber], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name], [PhoneNumber])
      VALUES (i.[Name], i.[PhoneNumber])
      OUTPUT INSERTED.[Id], i._Position;
info: InfoMessageLogger[1]
      Table 'Customers'. Scan count 0, logical reads 5, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SET STATISTICS IO ON;
      SELECT TOP(2) [c].[Id], [c].[Name], [c].[PhoneNumber]
      FROM [Customers] AS [c]
      WHERE [c].[Name] = N'Alice'
info: InfoMessageLogger[1]
      Table 'Customers'. Scan count 1, logical reads 2, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.

查询增强功能

EF7 包含 LINQ 查询转换中的许多改进。

GroupBy 作为最终运算符

提示

此处显示的代码来自 GroupByFinalOperatorSample.cs

EF7 支持使用 GroupBy 作为查询中的最终运算符。 例如,使用 SQL Server 时,以下 LINQ 查询:

var query = context.Books.GroupBy(s => s.Price);

使用 SQL Server 时,转换为以下 SQL:

SELECT [b].[Price], [b].[Id], [b].[AuthorId]
FROM [Books] AS [b]
ORDER BY [b].[Price]

备注

这种类型的 GroupBy 不会直接转换为 SQL,因此 EF Core 对返回的结果执行分组。 但是,这不会导致从服务器传输任何其他数据。

GroupJoin 作为最终运算符

提示

此处显示的代码来自 GroupJoinFinalOperatorSample.cs

EF7 支持使用 GroupJoin 作为查询中的最终运算符。 例如,使用 SQL Server 时,以下 LINQ 查询:

var query = context.Customers.GroupJoin(
    context.Orders, c => c.Id, o => o.CustomerId, (c, os) => new { Customer = c, Orders = os });

使用 SQL Server 时,转换为以下 SQL:

SELECT [c].[Id], [c].[Name], [t].[Id], [t].[Amount], [t].[CustomerId]
FROM [Customers] AS [c]
OUTER APPLY (
    SELECT [o].[Id], [o].[Amount], [o].[CustomerId]
    FROM [Orders] AS [o]
    WHERE [c].[Id] = [o].[CustomerId]
) AS [t]
ORDER BY [c].[Id]

GroupBy 实体类型

提示

此处显示的代码来自 GroupByEntityTypeSample.cs

EF7 支持按实体类型进行分组。 例如,使用 SQL Server 时,以下 LINQ 查询:

var query = context.Books
    .GroupBy(s => s.Author)
    .Select(s => new { Author = s.Key, MaxPrice = s.Max(p => p.Price) });

使用 SQLite 时转换为以下 SQL:

SELECT [a].[Id], [a].[Name], MAX([b].[Price]) AS [MaxPrice]
FROM [Books] AS [b]
INNER JOIN [Author] AS [a] ON [b].[AuthorId] = [a].[Id]
GROUP BY [a].[Id], [a].[Name]

请记住,按唯一属性(如主键)进行分组将始终比按实体类型分组更有效。 但是,按实体类型分组可用于键和无键实体类型。

此外,使用主键按实体类型进行分组将始终为每个实体实例生成一个组,因为每个实体必须具有唯一键值。 有时,需要切换查询的源,这样就不需要进行分组。 例如,以下查询返回与上一个查询相同的结果:

var query = context.Authors
    .Select(a => new { Author = a, MaxPrice = a.Books.Max(b => b.Price) });

使用 SQLite 时,此查询将转换为以下 SQL:

SELECT [a].[Id], [a].[Name], (
    SELECT MAX([b].[Price])
    FROM [Books] AS [b]
    WHERE [a].[Id] = [b].[AuthorId]) AS [MaxPrice]
FROM [Authors] AS [a]

子查询不会从外部查询引用未分组的列

提示

此处显示的代码来自 UngroupedColumnsQuerySample.cs

在 EF Core 6.0 中,GROUP BY 子句将引用外部查询中的列,这些列在某些数据库中失败,但在其他查询中效率低下。 例如,考虑以下查询:

var query = from s in (from i in context.Invoices
                       group i by i.History.Month
                       into g
                       select new { Month = g.Key, Total = g.Sum(p => p.Amount), })
            select new
            {
                s.Month, s.Total, Payment = context.Payments.Where(p => p.History.Month == s.Month).Sum(p => p.Amount)
            };

在 SQL Server 上的 EF Core 6.0 中,这将转换为:

SELECT DATEPART(month, [i].[History]) AS [Month], COALESCE(SUM([i].[Amount]), 0.0) AS [Total], (
    SELECT COALESCE(SUM([p].[Amount]), 0.0)
    FROM [Payments] AS [p]
    WHERE DATEPART(month, [p].[History]) = DATEPART(month, [i].[History])) AS [Payment]
FROM [Invoices] AS [i]
GROUP BY DATEPART(month, [i].[History])

在 EF7 上,转换为:

SELECT [t].[Key] AS [Month], COALESCE(SUM([t].[Amount]), 0.0) AS [Total], (
    SELECT COALESCE(SUM([p].[Amount]), 0.0)
    FROM [Payments] AS [p]
    WHERE DATEPART(month, [p].[History]) = [t].[Key]) AS [Payment]
FROM (
    SELECT [i].[Amount], DATEPART(month, [i].[History]) AS [Key]
    FROM [Invoices] AS [i]
) AS [t]
GROUP BY [t].[Key]

只读集合可用于 Contains

提示

此处显示的代码来自 ReadOnlySetQuerySample.cs

当要搜索的项包含在 IReadOnlySetIReadOnlyCollectionIReadOnlyList 中时,EF7 支持使用 Contains。 例如,使用 SQL Server 时,以下 LINQ 查询:

IReadOnlySet<int> searchIds = new HashSet<int> { 1, 3, 5 };
var query = context.Customers.Where(p => p.Orders.Any(l => searchIds.Contains(l.Id)));

使用 SQL Server 时,转换为以下 SQL:

SELECT [c].[Id], [c].[Name]
FROM [Customers] AS [c]
WHERE EXISTS (
    SELECT 1
    FROM [Orders] AS [o]
    WHERE [c].[Id] = [o].[Customer1Id] AND [o].[Id] IN (1, 3, 5))

聚合函数的转换

EF7 为提供程序引入了更好的扩展性来转换聚合函数。 本领域的这一工作和其他工作导致了跨提供商的多个新转换,包括:

备注

IEnumerable 参数执行操作的聚合函数通常仅在 GroupBy 查询中转换。 如果有兴趣删除此限制,请投票支持 JSON 列中支持空间类型

字符串聚合函数

提示

此处显示的代码来自 StringAggregateFunctionsSample.cs

使用 JoinConcat 的查询现在在适当的时候进行转换。 例如:

var query = context.Posts
    .GroupBy(post => post.Author)
    .Select(grouping => new { Author = grouping.Key, Books = string.Join("|", grouping.Select(post => post.Title)) });

使用 SQL Server 时,此查询转换为以下内容:

SELECT [a].[Id], [a].[Name], COALESCE(STRING_AGG([p].[Title], N'|'), N'') AS [Books]
FROM [Posts] AS [p]
LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
GROUP BY [a].[Id], [a].[Name]

与其他字符串函数结合使用时,这些转换允许服务器上的一些复杂字符串操作。 例如:

var query = context.Posts
    .GroupBy(post => post.Author!.Name)
    .Select(
        grouping =>
            new
            {
                PostAuthor = grouping.Key,
                Blogs = string.Concat(
                    grouping
                        .Select(post => post.Blog.Name)
                        .Distinct()
                        .Select(postName => "'" + postName + "' ")),
                ContentSummaries = string.Join(
                    " | ",
                    grouping
                        .Where(post => post.Content.Length >= 10)
                        .Select(post => "'" + post.Content.Substring(0, 10) + "' "))
            });

使用 SQL Server 时,此查询转换为以下内容:

SELECT [t].[Name], (N'''' + [t0].[Name]) + N''' ', [t0].[Name], [t].[c]
FROM (
    SELECT [a].[Name], COALESCE(STRING_AGG(CASE
        WHEN CAST(LEN([p].[Content]) AS int) >= 10 THEN COALESCE((N'''' + COALESCE(SUBSTRING([p].[Content], 0 + 1, 10), N'')) + N''' ', N'')
    END, N' | '), N'') AS [c]
    FROM [Posts] AS [p]
    LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
    GROUP BY [a].[Name]
) AS [t]
OUTER APPLY (
    SELECT DISTINCT [b].[Name]
    FROM [Posts] AS [p0]
    LEFT JOIN [Authors] AS [a0] ON [p0].[AuthorId] = [a0].[Id]
    INNER JOIN [Blogs] AS [b] ON [p0].[BlogId] = [b].[Id]
    WHERE [t].[Name] = [a0].[Name] OR ([t].[Name] IS NULL AND [a0].[Name] IS NULL)
) AS [t0]
ORDER BY [t].[Name]

空间聚合函数

提示

此处显示的代码来自 SpatialAggregateFunctionsSample.cs

现在,支持 NetTopologySuite 的数据库提供程序可以转换以下空间聚合函数:

提示

SQL Server 和 SQLite 团队已实施这些转换。 对于其他提供程序,请联系提供程序维护人员,以便在为该提供程序实现支持时添加支持。

例如:

var query = context.Caches
    .Where(cache => cache.Location.X < -90)
    .GroupBy(cache => cache.Owner)
    .Select(
        grouping => new { Id = grouping.Key, Combined = GeometryCombiner.Combine(grouping.Select(cache => cache.Location)) });

使用 SQL Server 时,此查询将转换为以下 SQL:

SELECT [c].[Owner] AS [Id], geography::CollectionAggregate([c].[Location]) AS [Combined]
FROM [Caches] AS [c]
WHERE [c].[Location].Long < -90.0E0
GROUP BY [c].[Owner]

空间聚合函数

提示

此处显示的代码来自 StatisticalAggregateFunctionsSample.cs

SQL Server 转换已针对以下统计函数实现:

提示

SQL Server 团队已实施这些转换。 对于其他提供程序,请联系提供程序维护人员,以便在为该提供程序实现支持时添加支持。

例如:

var query = context.Downloads
    .GroupBy(download => download.Uploader.Id)
    .Select(
        grouping => new
        {
            Author = grouping.Key,
            TotalCost = grouping.Sum(d => d.DownloadCount),
            AverageViews = grouping.Average(d => d.DownloadCount),
            VariancePopulation = EF.Functions.VariancePopulation(grouping.Select(d => d.DownloadCount)),
            VarianceSample = EF.Functions.VarianceSample(grouping.Select(d => d.DownloadCount)),
            StandardDeviationPopulation = EF.Functions.StandardDeviationPopulation(grouping.Select(d => d.DownloadCount)),
            StandardDeviationSample = EF.Functions.StandardDeviationSample(grouping.Select(d => d.DownloadCount))
        });

使用 SQL Server 时,此查询将转换为以下 SQL:

SELECT [u].[Id] AS [Author], COALESCE(SUM([d].[DownloadCount]), 0) AS [TotalCost], AVG(CAST([d].[DownloadCount] AS float)) AS [AverageViews], VARP([d].[DownloadCount]) AS [VariancePopulation], VAR([d].[DownloadCount]) AS [VarianceSample], STDEVP([d].[DownloadCount]) AS [StandardDeviationPopulation], STDEV([d].[DownloadCount]) AS [StandardDeviationSample]
FROM [Downloads] AS [d]
INNER JOIN [Uploader] AS [u] ON [d].[UploaderId] = [u].[Id]
GROUP BY [u].[Id]

string.IndexOf 的转换

提示

此处显示的代码来自 MiscellaneousTranslationsSample.cs

EF7 现在在 LINQ 查询中转换 String.IndexOf。 例如:

var query = context.Posts
    .Select(post => new { post.Title, IndexOfEntity = post.Content.IndexOf("Entity") })
    .Where(post => post.IndexOfEntity > 0);

使用 SQL Server 时,此查询将转换为以下 SQL:

SELECT [p].[Title], CAST(CHARINDEX(N'Entity', [p].[Content]) AS int) - 1 AS [IndexOfEntity]
FROM [Posts] AS [p]
WHERE (CAST(CHARINDEX(N'Entity', [p].[Content]) AS int) - 1) > 0

实体类型的 GetType 转换

提示

此处显示的代码来自 MiscellaneousTranslationsSample.cs

EF7 现在在 LINQ 查询中转换 Object.GetType()。 例如:

var query = context.Posts.Where(post => post.GetType() == typeof(Post));

此查询在将 SQL Server 与 TPH 继承结合使用时转换为以下 SQL:

SELECT [p].[Id], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText]
FROM [Posts] AS [p]
WHERE [p].[Discriminator] = N'Post'

请注意,此查询仅返回实际属于 Post 类型的 Post 实例,而不是任何派生类型的实例。 这不同于使用 isOfType 的查询,后者也会返回任何派生类型的实例。 例如,请考虑以下查询:

var query = context.Posts.OfType<Post>();

这转换为不同的 SQL:

      SELECT [p].[Id], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText]
      FROM [Posts] AS [p]

并将同时返回 PostFeaturedPost 实体。

AT TIME ZONE 支持

提示

此处显示的代码来自 MiscellaneousTranslationsSample.cs

EF7 为 DateTimeDateTimeOffset 引入了新的 AtTimeZone 函数。 这些函数转换为生成的 SQL 中的 AT TIME ZONE 子句。 例如:

var query = context.Posts
    .Select(
        post => new
        {
            post.Title,
            PacificTime = EF.Functions.AtTimeZone(post.PublishedOn, "Pacific Standard Time"),
            UkTime = EF.Functions.AtTimeZone(post.PublishedOn, "GMT Standard Time"),
        });

使用 SQL Server 时,此查询将转换为以下 SQL:

SELECT [p].[Title], [p].[PublishedOn] AT TIME ZONE 'Pacific Standard Time' AS [PacificTime], [p].[PublishedOn] AT TIME ZONE 'GMT Standard Time' AS [UkTime]
FROM [Posts] AS [p]

提示

SQL Server 团队已实施这些转换。 对于其他提供程序,请联系提供程序维护人员,以便在为该提供程序实现支持时添加支持。

隐藏导航上筛选的 Include

提示

此处显示的代码来自 MiscellaneousTranslationsSample.cs

Include 方法现在可用于 EF.Property。 这允许筛选和排序,即使是专用导航属性,也允许由字段表示的专用导航。 例如:

var query = context.Blogs.Include(
    blog => EF.Property<ICollection<Post>>(blog, "Posts")
        .Where(post => post.Content.Contains(".NET"))
        .OrderBy(post => post.Title));

这等效于:

var query = context.Blogs.Include(
    blog => Posts
        .Where(post => post.Content.Contains(".NET"))
        .OrderBy(post => post.Title));

但不需要公开访问 Blog.Posts

使用 SQL Server 时,上述两个查询都转换为:

SELECT [b].[Id], [b].[Name], [t].[Id], [t].[AuthorId], [t].[BlogId], [t].[Content], [t].[Discriminator], [t].[PublishedOn], [t].[Title], [t].[PromoText]
FROM [Blogs] AS [b]
LEFT JOIN (
    SELECT [p].[Id], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText]
    FROM [Posts] AS [p]
    WHERE [p].[Content] LIKE N'%.NET%'
) AS [t] ON [b].[Id] = [t].[BlogId]
ORDER BY [b].[Id], [t].[Title]

用于 Regex.IsMatch的 Cosmos 转换

提示

此处显示的代码来自 CosmosQueriesSample.cs

EF7 支持在针对 Azure Cosmos DB 的 LINQ 查询中使用 Regex.IsMatch。 例如:

var containsInnerT = await context.Triangles
    .Where(o => Regex.IsMatch(o.Name, "[a-z]t[a-z]", RegexOptions.IgnoreCase))
    .ToListAsync();

转换为以下 SQL:

SELECT c
FROM root c
WHERE ((c["Discriminator"] = "Triangle") AND RegexMatch(c["Name"], "[a-z]t[a-z]", "i"))

DbContext API 和行为增强

EF7 包含对 DbContext 和相关类的各种小改进。

提示

本节中的示例代码来自 DbContextApiSample.cs

未初始化的 DbSet 属性的抑制器

构造 DbContext 时,EF Core 会自动初始化 DbContext 上的公共、可设置 DbSet 属性。 例如,请考虑以下 DbContext 定义:

public class SomeDbContext : DbContext
{
    public DbSet<Blog> Blogs { get; set; }
}

Blogs 属性将设置为 DbSet<Blog> 实例,作为构造 DbContext 实例的一部分。 这样,上下文就可以用于查询,而无需执行任何其他步骤。

但是,在引入 C# 可为空引用类型后,编译器现在警告无法初始化不可为 null 的属性 Blogs

[CS8618] Non-nullable property 'Blogs' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.

这是一个虚假警告;该属性由 EF Core 设置为非 null 值。 此外,将属性声明可为空将使警告消失,但这不是一个好主意,因为从概念上讲,该属性不可为空,永远不会为空。

EF7 包含 DiagnosticSuppressor,用于 DbContext 上的 DbSet 属性,这会阻止编译器生成此警告。

提示

此模式起源于 C# 自动属性非常有限的时候。 使用新式 C# 时,请考虑将自动属性设置为只读,然后在 DbContext 构造函数中显式初始化它们,或在需要时从上下文中获取缓存的 DbSet 实例。 例如 public DbSet<Blog> Blogs => Set<Blog>()

区分日志中的取消与失败

有时,应用程序会显式取消查询或其他数据库操作。 这通常是使用传递给执行操作的方法的 CancellationToken 完成的。

在 EF Core 6 中,取消操作时记录的事件与由于其他原因而失败时记录的事件相同。 EF7 引入了专门针对已取消的数据库操作的新日志事件。 默认情况下,这些新事件在 Debug 级别记录。 下表显示了相关事件及其默认日志级别:

事件 说明 默认日志级别
CoreEventId.QueryIterationFailed 处理查询结果时出错。 LogLevel.Error
CoreEventId.SaveChangesFailed 尝试保存对数据库的更改时出错。 LogLevel.Error
RelationalEventId.CommandError 执行数据库命令时出错。 LogLevel.Error
CoreEventId.QueryCanceled 查询已取消。 LogLevel.Debug
CoreEventId.SaveChangesCanceled 尝试保存更改时,数据库命令已取消。 LogLevel.Debug
RelationalEventId.CommandCanceled 已取消执行 DbCommand LogLevel.Debug

备注

通过查看异常而不是检查取消令牌来检测取消。 这意味着,不通过取消令牌触发的取消仍会以这种方式检测和记录。

EntityEntry 方法的新 IPropertyINavigation 重载

使用 EF 模型的代码通常具有表示属性或导航元数据的 IPropertyINavigation。 然后,使用 EntityEntry 获取属性/导航值或查询其状态。 但是,在 EF7 之前,这需要将属性的名称或导航传递给 EntityEntry 的方法,然后重新查找 IPropertyINavigation。 在 EF7 中,可以直接传递 IPropertyINavigation,避免进行其他查找。

例如,请考虑一种方法来查找给定实体的所有同级:

public static IEnumerable<TEntity> FindSiblings<TEntity>(
    this DbContext context, TEntity entity, string navigationToParent)
    where TEntity : class
{
    var parentEntry = context.Entry(entity).Reference(navigationToParent);

    return context.Entry(parentEntry.CurrentValue!)
        .Collection(parentEntry.Metadata.Inverse!)
        .CurrentValue!
        .OfType<TEntity>()
        .Where(e => !ReferenceEquals(e, entity));
}

此方法查找给定实体的父实体,然后将反向 INavigation 传递给父项的 Collection 方法。 然后,此元数据用于返回给定父级的所有同级。 下面是其用法的示例:


Console.WriteLine($"Siblings to {post.Id}: '{post.Title}' are...");
foreach (var sibling in context.FindSiblings(post, nameof(post.Blog)))
{
    Console.WriteLine($"    {sibling.Id}: '{sibling.Title}'");
}

输出:

Siblings to 1: 'Announcing Entity Framework 7 Preview 7: Interceptors!' are...
    5: 'Productivity comes to .NET MAUI in Visual Studio 2022'
    6: 'Announcing .NET 7 Preview 7'
    7: 'ASP.NET Core updates in .NET 7 Preview 7'

共享类型实体类型的 EntityEntry

EF Core 可以将同一 CLR 类型用于多个不同的实体类型。 这些实体类型称为“共享类型实体类型”,通常用于映射用于实体类型的属性的键/值对的字典类型。 例如,可以定义 BuildMetadata 实体类型,而无需定义专用 CLR 类型:

modelBuilder.SharedTypeEntity<Dictionary<string, object>>(
    "BuildMetadata", b =>
    {
        b.IndexerProperty<int>("Id");
        b.IndexerProperty<string>("Tag");
        b.IndexerProperty<Version>("Version");
        b.IndexerProperty<string>("Hash");
        b.IndexerProperty<bool>("Prerelease");
    });

请注意,必须命名共享类型实体类型 - 在这种情况下,名称为 BuildMetadata。 然后,可以使用使用名称获取的实体类型的 DbSet 访问这些实体类型。 例如:

public DbSet<Dictionary<string, object>> BuildMetadata
    => Set<Dictionary<string, object>>("BuildMetadata");

DbSet 可用于跟踪实体实例:

await context.BuildMetadata.AddAsync(
    new Dictionary<string, object>
    {
        { "Tag", "v7.0.0-rc.1.22426.7" },
        { "Version", new Version(7, 0, 0) },
        { "Prerelease", true },
        { "Hash", "dc0f3e8ef10eb1464b27f0fd4704f53c01226036" }
    });

并执行查询:

var builds = await context.BuildMetadata
    .Where(metadata => !EF.Property<bool>(metadata, "Prerelease"))
    .OrderBy(metadata => EF.Property<string>(metadata, "Tag"))
    .ToListAsync();

现在,在 EF7 中,DbSet 上还有一个 Entry 方法,可用于获取实例的状态,即使尚未跟踪也是如此。 例如:

var state = context.BuildMetadata.Entry(build).State;

ContextInitialized 现在记录为 Debug

在 EF7 中,ContextInitialized 事件记录在 Debug 级别。 例如:

dbug: 10/7/2022 12:27:52.379 CoreEventId.ContextInitialized[10403] (Microsoft.EntityFrameworkCore.Infrastructure)
      Entity Framework Core 7.0.0 initialized 'BlogsContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer:7.0.0' with options: SensitiveDataLoggingEnabled using NetTopologySuite

在以前的版本中,它记录在 Information 级别。 例如:

info: 10/7/2022 12:30:34.757 CoreEventId.ContextInitialized[10403] (Microsoft.EntityFrameworkCore.Infrastructure)
      Entity Framework Core 7.0.0 initialized 'BlogsContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer:7.0.0' with options: SensitiveDataLoggingEnabled using NetTopologySuite

如果需要,可以将日志级别更改回 Information

optionsBuilder.ConfigureWarnings(
    builder =>
    {
        builder.Log((CoreEventId.ContextInitialized, LogLevel.Information));
    });

IEntityEntryGraphIterator 可公开使用

在 EF7 中,应用程序可以使用 IEntityEntryGraphIterator 服务。 这是在发现要跟踪的实体图时以及通过 TrackGraph 在内部使用的服务。 以下示例循环访问可从某些起始实体访问的所有实体:

var blogEntry = context.ChangeTracker.Entries<Blog>().First();
var found = new HashSet<object>();
var iterator = context.GetService<IEntityEntryGraphIterator>();
iterator.TraverseGraph(new EntityEntryGraphNode<HashSet<object>>(blogEntry, found, null, null), node =>
{
    if (node.NodeState.Contains(node.Entry.Entity))
    {
        return false;
    }

    Console.Write($"Found with '{node.Entry.Entity.GetType().Name}'");

    if (node.InboundNavigation != null)
    {
        Console.Write($" by traversing '{node.InboundNavigation.Name}' from '{node.SourceEntry!.Entity.GetType().Name}'");
    }

    Console.WriteLine();

    node.NodeState.Add(node.Entry.Entity);

    return true;
});

Console.WriteLine();
Console.WriteLine($"Finished iterating. Found {found.Count} entities.");
Console.WriteLine();

注意:

  • 回调委托返回 false 时,迭代器停止从给定节点遍历。 此示例跟踪已访问的实体,并在访问实体时返回 false。 这可以防止由图形中的周期产生的无限循环。
  • EntityEntryGraphNode<TState> 对象允许在不将其捕获到委托的情况下传递状态。
  • 对于除第一个节点之外访问的每个节点,它从中发现的节点及其通过节点发现的导航将传递给回调。

模型构建增强功能

EF7 包含模型构建中的各种小改进。

提示

本节中的示例代码来自 ModelBuildingSample.cs

索引可以是升序或降序

默认情况下,EF Core 会创建升序索引。 EF7 还支持创建降序索引。 例如:

modelBuilder
    .Entity<Post>()
    .HasIndex(post => post.Title)
    .IsDescending();

或者,使用 Index 映射属性:

[Index(nameof(Title), AllDescending = true)]
public class Post
{
    public int Id { get; set; }

    [MaxLength(64)]
    public string? Title { get; set; }
}

这对于单列上的索引很少有用,因为数据库可以使用同一索引在两个方向上排序。 但是,对于多个列上的复合索引来说,情况并非如此,其中每个列的顺序可能都很重要。 EF Core 通过允许多个列为每个列定义不同的排序来支持此功能。 例如:

modelBuilder
    .Entity<Blog>()
    .HasIndex(blog => new { blog.Name, blog.Owner })
    .IsDescending(false, true);

或者,使用映射属性:

[Index(nameof(Name), nameof(Owner), IsDescending = new[] { false, true })]
public class Blog
{
    public int Id { get; set; }

    [MaxLength(64)]
    public string? Name { get; set; }

    [MaxLength(64)]
    public string? Owner { get; set; }

    public List<Post> Posts { get; } = new();
}

使用 SQL Server 时,这会导致以下 SQL:

CREATE INDEX [IX_Blogs_Name_Owner] ON [Blogs] ([Name], [Owner] DESC);

最后,可以通过提供索引名称,通过相同的有序列集创建多个索引。 例如:

modelBuilder
    .Entity<Blog>()
    .HasIndex(blog => new { blog.Name, blog.Owner }, "IX_Blogs_Name_Owner_1")
    .IsDescending(false, true);

modelBuilder
    .Entity<Blog>()
    .HasIndex(blog => new { blog.Name, blog.Owner }, "IX_Blogs_Name_Owner_2")
    .IsDescending(true, true);

或者,使用映射属性:

[Index(nameof(Name), nameof(Owner), IsDescending = new[] { false, true }, Name = "IX_Blogs_Name_Owner_1")]
[Index(nameof(Name), nameof(Owner), IsDescending = new[] { true, true }, Name = "IX_Blogs_Name_Owner_2")]
public class Blog
{
    public int Id { get; set; }

    [MaxLength(64)]
    public string? Name { get; set; }

    [MaxLength(64)]
    public string? Owner { get; set; }

    public List<Post> Posts { get; } = new();
}

这会在 SQL Server 上生成以下 SQL:

CREATE INDEX [IX_Blogs_Name_Owner_1] ON [Blogs] ([Name], [Owner] DESC);
CREATE INDEX [IX_Blogs_Name_Owner_2] ON [Blogs] ([Name] DESC, [Owner] DESC);

复合键的映射属性

EF7 引入了一个新的映射属性(即“数据注释”),用于指定任何实体类型的主键属性或属性。 与 System.ComponentModel.DataAnnotations.KeyAttribute 不同,PrimaryKeyAttribute 放置在实体类型类而不是键属性上。 例如:

[PrimaryKey(nameof(PostKey))]
public class Post
{
    public int PostKey { get; set; }
}

这使得它很适合定义组合键:

[PrimaryKey(nameof(PostId), nameof(CommentId))]
public class Comment
{
    public int PostId { get; set; }
    public int CommentId { get; set; }
    public string CommentText { get; set; } = null!;
}

定义类上的索引也意味着它可用于将私有属性或字段指定为键,即使生成 EF 模型时通常会忽略这些属性。 例如:

[PrimaryKey(nameof(_id))]
public class Tag
{
    private readonly int _id;
}

DeleteBehavior 映射属性

EF7 引入了映射属性(即“数据注释”)来指定关系的 DeleteBehavior。 例如,默认情况下,使用 DeleteBehavior.Cascade 创建必需的关系。 默认情况下,可以使用 DeleteBehaviorAttribute 更改为 DeleteBehavior.NoAction

public class Post
{
    public int Id { get; set; }
    public string? Title { get; set; }

    [DeleteBehavior(DeleteBehavior.NoAction)]
    public Blog Blog { get; set; } = null!;
}

这将禁用博客文章关系的级联删除。

映射到不同列名称的属性

某些映射模式会导致同一 CLR 属性映射到多个不同表的每个表中的一列。 EF7 允许这些列具有不同的名称。 例如,来看看简单的继承层次结构:

public abstract class Animal
{
    public int Id { get; set; }
    public string Breed { get; set; } = null!;
}

public class Cat : Animal
{
    public string? EducationalLevel { get; set; }
}

public class Dog : Animal
{
    public string? FavoriteToy { get; set; }
}

使用 TPT 继承映射策略,这些类型将映射到三个表。 但是,每个表中的主键列可能具有不同的名称。 例如:

CREATE TABLE [Animals] (
    [Id] int NOT NULL IDENTITY,
    [Breed] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Animals] PRIMARY KEY ([Id])
);

CREATE TABLE [Cats] (
    [CatId] int NOT NULL,
    [EducationalLevel] nvarchar(max) NULL,
    CONSTRAINT [PK_Cats] PRIMARY KEY ([CatId]),
    CONSTRAINT [FK_Cats_Animals_CatId] FOREIGN KEY ([CatId]) REFERENCES [Animals] ([Id]) ON DELETE CASCADE
);

CREATE TABLE [Dogs] (
    [DogId] int NOT NULL,
    [FavoriteToy] nvarchar(max) NULL,
    CONSTRAINT [PK_Dogs] PRIMARY KEY ([DogId]),
    CONSTRAINT [FK_Dogs_Animals_DogId] FOREIGN KEY ([DogId]) REFERENCES [Animals] ([Id]) ON DELETE CASCADE
);

EF7 允许使用嵌套表生成器配置此映射:

modelBuilder.Entity<Animal>().ToTable("Animals");

modelBuilder.Entity<Cat>()
    .ToTable(
        "Cats",
        tableBuilder => tableBuilder.Property(cat => cat.Id).HasColumnName("CatId"));

modelBuilder.Entity<Dog>()
    .ToTable(
        "Dogs",
        tableBuilder => tableBuilder.Property(dog => dog.Id).HasColumnName("DogId"));

借助 TPC 继承映射,Breed 属性也可以映射到不同表中的不同列名。 例如,来看看以下 TPC 表:

CREATE TABLE [Cats] (
    [CatId] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [CatBreed] nvarchar(max) NOT NULL,
    [EducationalLevel] nvarchar(max) NULL,
    CONSTRAINT [PK_Cats] PRIMARY KEY ([CatId])
);

CREATE TABLE [Dogs] (
    [DogId] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [DogBreed] nvarchar(max) NOT NULL,
    [FavoriteToy] nvarchar(max) NULL,
    CONSTRAINT [PK_Dogs] PRIMARY KEY ([DogId])
);

EF7 支持此表映射:

modelBuilder.Entity<Animal>().UseTpcMappingStrategy();

modelBuilder.Entity<Cat>()
    .ToTable(
        "Cats",
        builder =>
        {
            builder.Property(cat => cat.Id).HasColumnName("CatId");
            builder.Property(cat => cat.Breed).HasColumnName("CatBreed");
        });

modelBuilder.Entity<Dog>()
    .ToTable(
        "Dogs",
        builder =>
        {
            builder.Property(dog => dog.Id).HasColumnName("DogId");
            builder.Property(dog => dog.Breed).HasColumnName("DogBreed");
        });

单向多对多关系

EF7 支持多对多关系,其中一方或另一方没有导航属性。 例如,请考虑 PostTag 类型:

public class Post
{
    public int Id { get; set; }
    public string? Title { get; set; }
    public Blog Blog { get; set; } = null!;
    public List<Tag> Tags { get; } = new();
}
public class Tag
{
    public int Id { get; set; }
    public string TagName { get; set; } = null!;
}

请注意,Post 类型具有标记列表的导航属性,但 Tag 类型没有文章的导航属性。 在 EF7 中,这仍然可以配置为多对多关系,允许将同一 Tag 对象用于许多不同的文章。 例如:

modelBuilder
    .Entity<Post>()
    .HasMany(post => post.Tags)
    .WithMany();

这会导致映射到相应的联接表:

CREATE TABLE [Tags] (
    [Id] int NOT NULL IDENTITY,
    [TagName] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Tags] PRIMARY KEY ([Id])
);

CREATE TABLE [Posts] (
    [Id] int NOT NULL IDENTITY,
    [Title] nvarchar(64) NULL,
    [BlogId] int NOT NULL,
    CONSTRAINT [PK_Posts] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Posts_Blogs_BlogId] FOREIGN KEY ([BlogId]) REFERENCES [Blogs] ([Id])
);

CREATE TABLE [PostTag] (
    [PostId] int NOT NULL,
    [TagsId] int NOT NULL,
    CONSTRAINT [PK_PostTag] PRIMARY KEY ([PostId], [TagsId]),
    CONSTRAINT [FK_PostTag_Posts_PostId] FOREIGN KEY ([PostId]) REFERENCES [Posts] ([Id]) ON DELETE CASCADE,
    CONSTRAINT [FK_PostTag_Tags_TagsId] FOREIGN KEY ([TagsId]) REFERENCES [Tags] ([Id]) ON DELETE CASCADE
);

这种关系可以正常地用作多对多关系。 例如,插入一些共享常见集中的各种标记的帖子:

var tags = new Tag[] { new() { TagName = "Tag1" }, new() { TagName = "Tag2" }, new() { TagName = "Tag2" }, };

await context.AddRangeAsync(new Blog { Posts =
{
    new Post { Tags = { tags[0], tags[1] } },
    new Post { Tags = { tags[1], tags[0], tags[2] } },
    new Post()
} });

await context.SaveChangesAsync();

实体拆分

实体拆分将单个实体类型映射到多个表。 例如,假设数据库包含三个保存客户数据的表:

  • Customers 表保存客户信息
  • PhoneNumbers 表保存客户的电话号码
  • Addresses 表保存客户的地址

以下是 SQL Server 中这些表的定义:

CREATE TABLE [Customers] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Customers] PRIMARY KEY ([Id])
);
    
CREATE TABLE [PhoneNumbers] (
    [CustomerId] int NOT NULL,
    [PhoneNumber] nvarchar(max) NULL,
    CONSTRAINT [PK_PhoneNumbers] PRIMARY KEY ([CustomerId]),
    CONSTRAINT [FK_PhoneNumbers_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id]) ON DELETE CASCADE
);

CREATE TABLE [Addresses] (
    [CustomerId] int NOT NULL,
    [Street] nvarchar(max) NOT NULL,
    [City] nvarchar(max) NOT NULL,
    [PostCode] nvarchar(max) NULL,
    [Country] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Addresses] PRIMARY KEY ([CustomerId]),
    CONSTRAINT [FK_Addresses_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id]) ON DELETE CASCADE
);

每个表通常映射到自己的实体类型,以及这些类型之间的关系。 但是,如果所有三个表始终一起使用,则将它们全部映射到单个实体类型可能更加方便。 例如:

public class Customer
{
    public Customer(string name, string street, string city, string? postCode, string country)
    {
        Name = name;
        Street = street;
        City = city;
        PostCode = postCode;
        Country = country;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public string? PhoneNumber { get; set; }
    public string Street { get; set; }
    public string City { get; set; }
    public string? PostCode { get; set; }
    public string Country { get; set; }
}

在 EF7 中,这是通过为实体类型中的每个拆分调用 SplitToTable 来实现的。 例如,以下代码将 Customer 实体类型拆分为上文所述的 CustomersPhoneNumbersAddresses 表:

modelBuilder.Entity<Customer>(
    entityBuilder =>
    {
        entityBuilder
            .ToTable("Customers")
            .SplitToTable(
                "PhoneNumbers",
                tableBuilder =>
                {
                    tableBuilder.Property(customer => customer.Id).HasColumnName("CustomerId");
                    tableBuilder.Property(customer => customer.PhoneNumber);
                })
            .SplitToTable(
                "Addresses",
                tableBuilder =>
                {
                    tableBuilder.Property(customer => customer.Id).HasColumnName("CustomerId");
                    tableBuilder.Property(customer => customer.Street);
                    tableBuilder.Property(customer => customer.City);
                    tableBuilder.Property(customer => customer.PostCode);
                    tableBuilder.Property(customer => customer.Country);
                });
    });

另请注意,如有必要,可以为每个表指定不同的主键列名称。

SQL Server UTF-8 字符串

ncharnvarchar 数据类型表示的 SQL Server Unicode 字符串存储为 UTF-16。 此外,charvarchar 数据类型用于存储支持各种字符集的非 Unicode 字符串。

从 SQL Server 2019 开始,可以使用 charvarchar 数据类型来存储具有 UTF-8 编码的 Unicode 字符串。 这是通过设置 UTF-8 排序规则之一来实现的。 例如,以下代码为 CommentText 列配置可变长度 SQL Server UTF-8 字符串:

modelBuilder
    .Entity<Comment>()
    .Property(comment => comment.CommentText)
    .HasColumnType("varchar(max)")
    .UseCollation("LATIN1_GENERAL_100_CI_AS_SC_UTF8");

此配置生成以下 SQL Server 列定义:

CREATE TABLE [Comment] (
    [PostId] int NOT NULL,
    [CommentId] int NOT NULL,
    [CommentText] varchar(max) COLLATE LATIN1_GENERAL_100_CI_AS_SC_UTF8 NOT NULL,
    CONSTRAINT [PK_Comment] PRIMARY KEY ([PostId], [CommentId])
);

临时表支持拥有的实体

EF Core SQL Server 临时表映射在 EF7 中得到了增强,以支持表共享。 最值得注意的是,从属单个实体的默认映射使用表共享。

例如,请考虑所有者实体类型 Employee 及其从属实体类型 EmployeeInfo

public class Employee
{
    public Guid EmployeeId { get; set; }
    public string Name { get; set; } = null!;

    public EmployeeInfo Info { get; set; } = null!;
}

public class EmployeeInfo
{
    public string Position { get; set; } = null!;
    public string Department { get; set; } = null!;
    public string? Address { get; set; }
    public decimal? AnnualSalary { get; set; }
}

如果这些类型映射到同一个表,则可以在 EF7 中将表设为临时表:

modelBuilder
    .Entity<Employee>()
    .ToTable(
        "Employees",
        tableBuilder =>
        {
            tableBuilder.IsTemporal();
            tableBuilder.Property<DateTime>("PeriodStart").HasColumnName("PeriodStart");
            tableBuilder.Property<DateTime>("PeriodEnd").HasColumnName("PeriodEnd");
        })
    .OwnsOne(
        employee => employee.Info,
        ownedBuilder => ownedBuilder.ToTable(
            "Employees",
            tableBuilder =>
            {
                tableBuilder.IsTemporal();
                tableBuilder.Property<DateTime>("PeriodStart").HasColumnName("PeriodStart");
                tableBuilder.Property<DateTime>("PeriodEnd").HasColumnName("PeriodEnd");
            }));

备注

问题 #29303跟踪此配置,让其变得更简单。 如果这是你想要看到实现的问题,请投票支持此问题。

改进了值生成

EF7 包括对键属性自动生成值的两项显著改进。

提示

本节中的示例代码来自 ValueGenerationSample.cs

DDD 防护类型的值生成

在域驱动设计 (DDD) 中,“受保护的密钥”可以提高密钥属性的类型安全性。 这是通过将密钥类型包装在另一种类型中来实现的,该类型特定于密钥的使用。 例如,以下代码定义产品密钥的 ProductId 类型,以及类别键的 CategoryId 类型。

public readonly struct ProductId
{
    public ProductId(int value) => Value = value;
    public int Value { get; }
}

public readonly struct CategoryId
{
    public CategoryId(int value) => Value = value;
    public int Value { get; }
}

然后,这些属性用于 ProductCategory 实体类型:

public class Product
{
    public Product(string name) => Name = name;
    public ProductId Id { get; set; }
    public string Name { get; set; }
    public CategoryId CategoryId { get; set; }
    public Category Category { get; set; } = null!;
}

public class Category
{
    public Category(string name) => Name = name;
    public CategoryId Id { get; set; }
    public string Name { get; set; }
    public List<Product> Products { get; } = new();
}

这使得无法意外将某个类别的 ID 分配给产品,反之亦然。

警告

与许多 DDD 概念一样,这种改进的类型安全性以额外的代码复杂性为代价。 值得考虑的是,例如,将产品 ID 分配给类别是否可能发生。 使事情简单化可能更有利于基本代码。

此处显示的受保护的密钥类型都包装 int 键值,这意味着将在映射的数据库表中使用整数值。 通过为此类型定义值转换器来实现:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Properties<ProductId>().HaveConversion<ProductIdConverter>();
    configurationBuilder.Properties<CategoryId>().HaveConversion<CategoryIdConverter>();
}

private class ProductIdConverter : ValueConverter<ProductId, int>
{
    public ProductIdConverter()
        : base(v => v.Value, v => new(v))
    {
    }
}

private class CategoryIdConverter : ValueConverter<CategoryId, int>
{
    public CategoryIdConverter()
        : base(v => v.Value, v => new(v))
    {
    }
}

备注

此处的代码使用 struct 类型。 这意味着它们具有适当的值类型语义,用作键。 如果改用 class 类型,则需要重写相等语义,或指定值比较器

在 EF7 中,基于值转换器的键类型可以使用自动生成的键值,只要基础类型支持此功能。 这是使用 ValueGeneratedOnAdd 以正常方式配置的:

modelBuilder.Entity<Product>().Property(product => product.Id).ValueGeneratedOnAdd();
modelBuilder.Entity<Category>().Property(category => category.Id).ValueGeneratedOnAdd();

默认情况下,当与 SQL Server 一起使用时,这将导致 IDENTITY 列:

CREATE TABLE [Categories] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Categories] PRIMARY KEY ([Id]));

CREATE TABLE [Products] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    [CategoryId] int NOT NULL,
    CONSTRAINT [PK_Products] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Products_Categories_CategoryId] FOREIGN KEY ([CategoryId]) REFERENCES [Categories] ([Id]) ON DELETE CASCADE);

在插入实体时,以正常方式使用它们生成键值:

MERGE [Categories] USING (
VALUES (@p0, 0),
(@p1, 1)) AS i ([Name], _Position) ON 1=0
WHEN NOT MATCHED THEN
INSERT ([Name])
VALUES (i.[Name])
OUTPUT INSERTED.[Id], i._Position;

SQL Server 的基于序列的密钥生成

EF Core 支持使用 SQL ServerIDENTITY生成键值,或基于数据库序列生成的键块的 Hi-Lo 模式。 EF7 引入了对附加到键的列默认约束的数据库序列的支持。 在最简单的形式中,这只需要告诉 EF Core 对键属性使用序列:

modelBuilder.Entity<Product>().Property(product => product.Id).UseSequence();

这会导致在数据库中定义序列:

CREATE SEQUENCE [ProductSequence] START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE NO CYCLE;

然后在键列默认约束中使用:

CREATE TABLE [Products] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [ProductSequence]),
    [Name] nvarchar(max) NOT NULL,
    [CategoryId] int NOT NULL,
    CONSTRAINT [PK_Products] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Products_Categories_CategoryId] FOREIGN KEY ([CategoryId]) REFERENCES [Categories] ([Id]) ON DELETE CASCADE);

备注

默认情况下,此形式的密钥生成在实体类型层次结构中用于使用 TPC 映射策略生成的密钥。

如果需要,可以为序列提供不同的名称和架构。 例如:

modelBuilder
    .Entity<Product>()
    .Property(product => product.Id)
    .UseSequence("ProductsSequence", "northwind");

序列的进一步配置通过在模型中进行显式配置来形成。 例如:

modelBuilder
    .HasSequence<int>("ProductsSequence", "northwind")
    .StartsAt(1000)
    .IncrementsBy(2);

迁移工具改进

EF7 包括使用 EF Core 迁移命令行工具的两项重大改进。

UseSqlServer 等接受 null

从配置文件读取连接字符串,然后将该连接字符串传递给 UseSqlServerUseSqlite 或其他提供程序的等效方法是很常见的。 例如:

services.AddDbContext<BloggingContext>(options =>
    options.UseSqlServer(Configuration.GetConnectionString("BloggingDatabase")));

应用迁移时,传递连接字符串也很常见。 例如:

dotnet ef database update --connection "Server=(localdb)\mssqllocaldb;Database=MyAppDb"

或使用迁移捆绑包时。

./bundle.exe --connection "Server=(localdb)\mssqllocaldb;Database=MyAppDb"

在这种情况下,即使未使用从配置读取的连接字符串,应用程序启动代码仍会尝试从配置中读取它,并将其传递给 UseSqlServer。 如果配置不可用,则会导致将 null 传递给 UseSqlServer。 在 EF7 中,只要连接字符串最终在以后设置,例如,将 --connection 传递给命令行工具即可。

备注

UseSqlServerUseSqlite 进行了此更改。 对于其他提供程序,如果尚未为该提供程序进行更改,请联系提供程序维护人员进行等效的更改。

检测工具何时运行

使用 dotnet-efPowerShell 命令时,EF Core 将运行应用程序代码。 有时可能需要检测这种情况,以防止在设计时执行不适当的代码。 例如,在启动时自动应用迁移的代码可能不应在设计时执行此操作。 在 EF7 中,可以使用 EF.IsDesignTime 标记来检测此问题:

if (!EF.IsDesignTime)
{
    await context.Database.MigrateAsync();
}

当应用程序代码代表工具运行时,EF Core 会将 IsDesignTime 设置为 true

代理的性能增强

EF Core 支持动态生成的代理,用于延迟加载更改跟踪。 使用这些代理时,EF7 包含两项性能改进:

  • 代理类型现已延迟创建。 这意味着 EF7 使用代理时的初始模型生成时间可以比 EF Core 6.0 快得多。
  • 代理现在可以与已编译的模型一起使用。

下面是具有 449 个实体类型、6390 个属性和 720 种关系的模型的一些性能结果。

方案 方法 平均值 错误 标准偏差
不带代理的 EF Core 6.0 TimeToFirstQuery 1.085 秒 0.0083 秒 0.0167 秒
带有更改跟踪代理的 EF Core 6.0 TimeToFirstQuery 13.01 秒 0.2040 秒 0.4110 秒
不带代理的 EF Core 7.0 TimeToFirstQuery 1.442 秒 0.0134 秒 0.0272 秒
带有更改跟踪代理的 EF Core 7.0 TimeToFirstQuery 1.446 秒 0.0160 秒 0.0323 秒
带有更改跟踪代理和已编译模型的 EF Core 7.0 TimeToFirstQuery 0.162 秒 0.0062 秒 0.0125 秒

因此,在这种情况下,EF7 中具有更改跟踪代理的模型执行第一个查询可以比 EF Core 6.0 快 80 倍。

一流的 Windows 窗体数据绑定

Windows 窗体团队对 Visual Studio Designer 体验进行了很大的改进。 这包括与 EF Core 很好地集成的数据绑定新体验

简言之,新体验为创建 ObjectDataSource 提供了 Visual Studio U.I.:

选择“类别”数据源类型

然后,可以使用一些简单的代码将其绑定到 EF Core DbSet

public partial class MainForm : Form
{
    private ProductsContext? dbContext;

    public MainForm()
    {
        InitializeComponent();
    }

    protected override void OnLoad(EventArgs e)
    {
        base.OnLoad(e);

        this.dbContext = new ProductsContext();

        this.dbContext.Categories.Load();
        this.categoryBindingSource.DataSource = dbContext.Categories.Local.ToBindingList();
    }

    protected override void OnClosing(CancelEventArgs e)
    {
        base.OnClosing(e);

        this.dbContext?.Dispose();
        this.dbContext = null;
    }
}

有关完整演练和可下载的 WinForms 示例应用程序,请参阅 Windows 窗体 入门