EF Core 8 中数组映射的初学者指南
摘要
EF Core 8 引入了将简单值的类型数组映射到数据库列的支持,因此可以在从 LINQ 查询生成的 SQL 中使用映射的语义。
2024年6月4日
Entity Framework Core 博客帖子可能会变得非常复杂!在这篇文章中,我们将尝试保持基本内容,同时传授比简单信息更多的内容。EF Core 8 已发布六个月了,它的重要新功能之一是数组映射。让我们开始吧!
提示 此处展示的所有代码均可从 GitHub 下载。要开始使用 EF Core,请参阅 安装 Entity Framework Core。
假设您想将以下 Post
类型的实例保存到关系数据库中:
public class Post
{
public int Id { get; set; }
public string Title { get; set; }
public string Contents { get; set; }
public string[] Tags { get; set; }
public DateTime[] Visits { get; set; }
}
这应该很简单,对吧?是的,对于 Id
、Title
和 Contents
属性来说确实如此。这些是简单的整数和字符串属性,因此我们可以只创建一个具有适当整数和字符串列类型的表。例如,在使用 Azure SQL 时,EF Core 8 默认将这些列映射为:
CREATE TABLE [Posts] (
[Id] int NOT NULL IDENTITY,
[Title] nvarchar(max) NOT NULL,
[Contents] nvarchar(max) NOT NULL,
[Tags] ??? NOT NULL,
[Visits] ??? NOT NULL,
CONSTRAINT [PK_Posts] PRIMARY KEY ([Id])
);
但是 Tags
和 Visits
怎么办呢?这些都是数组属性,而大多数关系数据库不支持数组类型。那么 EF Core 8 如何处理呢?对于支持数组类型的数据库(如 PostgreSQL),EF Core 8 会直接使用这些类型。但对于大多数不原生支持数组类型的数据库,EF Core 8 会自动使用 JSON 数组。这对使用 EF Core 8 的应用程序开发人员来说基本上是透明的;您只需要编写 LINQ 查询,EF Core 8 会为您正在使用的目标数据库生成最合适的翻译。
PostgreSQL 数组列
如果我们的数据库支持数组类型,那么 EF Core 8 会自动使用它们。例如,在 PostgreSQL 上,我们的 Posts
表的完整映射如下:
CREATE TABLE "Posts" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
"Title" text NOT NULL,
"Contents" text NOT NULL,
"Tags" text[] NOT NULL,
"Visits" timestamp with time zone[] NOT NULL,
CONSTRAINT "PK_Posts" PRIMARY KEY ("Id")
);
注意 Title
和 Contents
列的类型为 text
,表明每行包含一个单独的“title”值或一个单独的“contents”值。而 Tags
列的类型为 text[]
,表明每行包含一个包含零到多个“tag”值的数组。下面是 Posts
表中的一些示例数据:
Id | Title | Contents | Tags | Visits |
---|---|---|---|---|
1 | Arrays in EF Core 8 | Imagine you want… | {EF Core,Entity Framework,.NET,Databases} | {2024-05-13 12:41:36.957711,2024-05-12 12:41:36.957714} |
2 | What’s new in Orleans 8 | Let’s take a look at … | {Orleans,.NET} | {2024-05-14 12:41:36.957779} |
3 | .NET at Build | Get ready for a | {.NET,ASP.NET Core} | {2024-05-12 12:41:36.957780} |
注意数组列每行可以包含多个值。
EF Core 8 然后会在查询翻译中使用这些数组列。例如,下面是一个 LINQ 查询,用于从 Tags
数组列中提取前两个标签:
var postTags = await context.Posts
.Select(post => new
{
PostTitle = post.Title,
FirstTag = post.Tags[0],
SecondTag = post.Tags[1]
}).ToListAsync();
在使用 PostgreSQL 时,EF Core 8 将此 LINQ 查询翻译为以下 SQL:
SELECT p."Title" AS "PostTitle",
p."Tags"[1] AS "FirstTag",
p."Tags"[2] AS "SecondTag"
FROM "Posts" AS p
注意 p."Tags"[1]
和 p."Tags"[2]
是如何索引数组以提取前两项的。
另一个常见的 LINQ 查询是查找 Tags
属性包含给定标签值的所有 Post
实例:
var tag = "EF Core";
var posts = await context.Posts
.Where(post => post.Tags.Contains(tag))
.ToListAsync();
在使用 PostgreSQL 数组列时,EF Core 8 将此翻译为:
SELECT p."Id", p."Contents", p."Tags", p."Title", p."Visits"
FROM "Posts" AS p
WHERE p."Tags" @> ARRAY[@__tag_0]::text[]
PostgreSQL 的语法有些特殊,所以如果你完全不理解这段 SQL 也不用担心。重点是 WHERE p."Tags" @> ARRAY[@__tag_0]::text[]
通过在 Tags
数组中查找传入的 tag
参数值进行过滤。
JSON 数组
上述情况对于 PostgreSQL 用户非常好,但对于使用 Azure SQL、SQLite 或其他不原生支持数组的数据库系统用户来说如何呢?在这些情况下,EF Core 8 会自动使用 JSON 数组。这意味着在 Azure SQL 上,上述 Post
类型映射为以下表:
CREATE TABLE [Posts] (
[Id] int NOT NULL IDENTITY,
[Title] nvarchar(max) NOT NULL,
[Contents] nvarchar(max) NOT NULL,
[Tags] nvarchar(max) NOT NULL,
[Visits] nvarchar(max) NOT NULL,
CONSTRAINT [PK_Posts] PRIMARY KEY ([Id])
);
对于 Azure SQL 而言,Tags
和 Visits
列只是简单的字符串列。然而,EF Core 8 知道的更多。EF Core 8 知道这些列实际上包含 JSON 数组,因此可以翻译利用这些知识的查询。例如,下面是相同的 LINQ 查询,用于从 Tags
列中提取前两个值:
var postTags = await context.Posts
.Select(post => new
{
PostTitle = post.Title,
FirstTag = post.Tags[0],
SecondTag = post.Tags[1]
}).ToListAsync();
在 Azure SQL 上,EF Core 8 将此 LINQ 查询翻译为:
SELECT [p].[Title] AS [PostTitle],
JSON_VALUE([p].[Tags], '$[0]') AS [FirstTag],
JSON_VALUE([p].[Tags], '$[1]') AS [SecondTag]
FROM [Posts] AS [p]
这与 PostgreSQL 的翻译非常相似。不同之处在于首先使用 JSON_VALUE
函数告诉 SQL Tags
是一个 JSON 文档。然后使用 $[0]
和 $[1]
选择 JSON 数组中第一和第二位置的标签值。
再来看上面的第二个 LINQ 查询:
var tag = "EF Core";
var posts = await context.Posts
.Where(post => post.Tags.Contains(tag))
.ToListAsync();
在使用 Azure SQL 时,此查询翻译为:
SELECT [p].[Id], [p].[Contents], [p].[Tags], [p].[Title], [p].[Visits]
FROM [Posts] AS [p]
WHERE @__tag_0 IN (
SELECT [t].[value]
FROM OPENJSON([p].[Tags]) WITH ([value] nvarchar(max) '$') AS [t]
)
在此情况下,查询使用 OPENJSON
将 Tags
列中的 JSON 数组转换为一种临时表。WHERE @__tag_0 IN
然后用于该临时表以查找包含给定 tag
参数值的行。
类型数组
目前所有示例都使用了字符串数组。然而,EF Core 8 可以处理任何简单类型的数组,包括数字、日期/时间、GUID 等。例如,上述 Post
类中的 Visits
属性是 DateTime
实例的数组。在 PostgreSQL 上,此属性映射为 timestamp with time zone[]
列。这里很明确这实际上是一个时间戳数组,而不是其他类型的数组。EF Core 8 会使用这些信息对数组中的值执行特定于时间戳类型的操作。例如,请考虑这个返回所有在给定年份访问过的帖子实例的 LINQ 查询:
var year = DateTime.UtcNow.Year;
var visited = await context.Posts
.Where(post => post.Visits.Any(v => v.Year == year))
.ToListAsync();
在 PostgreSQL 上,这将翻译为:
SELECT p."Id", p."Contents", p."Tags", p."Title", p."Visits"
FROM "Posts" AS p
WHERE EXISTS (
SELECT 1
FROM unnest(p."Visits") AS v(value)
WHERE date_part('year', v.value AT TIME ZONE 'UTC')::int = @__year_0)
注意 date_part
函数如何用于从每个时间戳中提取年份。这只因为数组被认为包含 timestamp
值。
在 Azure SQL 中,类型不能包含在列定义中,这只是一个简单的字符串列:[Visits] nvarchar(max) NOT NULL
。然而,EF Core 8 知道这是一个包含时间戳的 JSON 列并能适当使用这些信息进行查询翻译:
SELECT [p].[Id], [p].[Contents], [p].[Tags], [p].[Title], [p].[Visits]
FROM [Posts] AS [p]
WHERE EXISTS (
SELECT 1
FROM OPENJSON([p].[Visits]) WITH ([value] datetime2 '$') AS [v]
WHERE DATEPART(year, [v].[value]) = @__year_0)
注意在 OPENJSON
语句中使用的 WITH ([value] datetime2 '$')
。这再次告知 Azure SQL 创建一个包含 datetime2
列的临时表。EF Core 8 然后可以编写操作这些 datetime2
值的查询,例如使用 DATEPART
函数提取年份。这仅因为 EF Core 8 理解 JSON 列中存储的内容。
数组的其他应用
EF Core 8 允许在大多数可以使用非数组简单类型的地方使用简单类型的数组。我们已经看到属性可以映射到数组列的示例。另一个例子是通过将多个值作为数组传递在一个参数中传递很多值。例如,假设我们希望返回标题以几个字符串之一开头的所有帖子。这里有一个 LINQ 查询来实现这一点:
var prefixes = new[] { "What's new", "Getting started", "Intro to" };
await context.Posts
.Where(post => prefixes.Any(prefix => post.Title.StartsWith(prefix)))
.ToListAsync();
查看 PostgreSQL 中此查询的 EF Core 8 日志,可以看到 prefixes
数组作为单个 PostgreSQL 数组参数传递:
info: 5/14/2024 14:34:20.970 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (1ms) [Parameters=[@__prefixes_0={ 'What's new', 'Getting started', 'Intro to' } (DbType = Object)], CommandType='Text', CommandTimeout='30']
SELECT p."Id", p."Contents", p."Tags", p."Title", p."Visits"
FROM "Posts" AS p
WHERE EXISTS (
SELECT 1
FROM unnest(@__prefixes_0) AS p0(value)
WHERE p0.value IS NOT NULL AND left(p."Title", length(p0.value)) = p0.value)
EF Core 8 然后会使用 PostgreSQL 的 unnest
函数应用所有传递到数组参数中的值的过滤器。
如果查看 Azure SQL 中的日志,我们会看到参数是一个 JSON 数组,包含在一个字符串参数中:
info: 5/14/2024 14:42:57.689 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (4ms) [Parameters=[@__prefixes_0='["What\u0027s new","Getting started","Intro to"]' (Size = 4000)], CommandType='Text', CommandTimeout='30']
SELECT [p].[Id], [p].[Contents], [p].[Tags], [p].[Title], [p].[Visits]
FROM [Posts] AS [p]
WHERE EXISTS (
SELECT 1
FROM OPENJSON(@__prefixes_0) WITH ([value] nvarchar(max) '$') AS [p0]
WHERE [p0].[value] IS NOT NULL AND LEFT([p].[Title], LEN([p0].[value])) = [p0].[value])
EF Core 8 生成的 SQL 使用 OPENJSON
创建一个临时表,不过这次是在参数值上。
将数组属性与数组参数组合使用允许翻译简短但强大的 LINQ 查询。例如,这个 LINQ 查询返回包含任何给定标签值的所有帖子:
var tags = new[] { ".NET", "ASP.NET Core" };
await context.Posts
.Where(post => tags.Any(tag => post.Tags.Contains(tag)))
.ToListAsync();
在具有原生数组类型的 PostgreSQL 上,此查询翻译为:
SELECT p."Id", p."Contents", p."Tags", p."Title", p."Visits"
FROM "Posts" AS p
WHERE @__tags_0 && p."Tags"
在使用 JSON 数组的 Azure SQL 上,SQL 如下:
SELECT [p].[Id], [p].[Contents], [p].[Tags], [p].[Title], [p].[Visits]
FROM [Posts] AS [p]
WHERE EXISTS (
SELECT 1
FROM OPENJSON(@__tags_0) WITH ([value] nvarchar(max) '$') AS [t]
WHERE [t].[value] IN (
SELECT [t0].[value]
FROM OPENJSON([p].[Tags]) WITH ([value] nvarchar(max) '$') AS [t0]
))
在两种情况下,值都作为数组参数传递,然后这些值与从数组列中取出的值一起使用。
常见问题
以下是一些人们关于数组映射常见问题。
- 如果使用 JSON 性能会很差吗?
- 也许,但在某些情况下,性能比其他技术更好。请查看“了解更多”部分中的链接。
- 可以使用数组以外的集合类型吗?
- 可以,上述所有内容适用于任何实现了
IList<T>
的集合。
- 可以,上述所有内容适用于任何实现了
- 可以映射嵌套集合或字典吗?
- 不行,在 EF Core 8 中不支持,但将在未来的 EF Core 版本中支持。
- 可以将为 JSON 创建的字符串列类型设置为
nvarchar(max)
以外的类型吗?- 可以,列类型完全可以配置。请参阅下文链接的“新功能”文档。
- 可以使用数组进行实体类型之间的导航吗?
- 不行,因为导航实现必须是可变的。
- 可以在数组中使用哪些类型?
- 可以使用数据库原生支持的任何类型,但还可以包括任何 EF 内置值转换器支持的类型,或者任何自定义的值转换器类型。
总结
EF Core 8 引入了将简单值的类型数组映射到数据库列的支持。如果可用,则使用本地数据库数组类型,否则 EF Core 8 使用包含 JSON 数组的字符串列。无论哪种情况,EF Core 8 都能理解映射的语义,从而可以执行依赖于数组中类型值的查询。简单值的数组还可以在其他地方使用,例如在单个参数中传递多个值给 EF Core。
了解更多
我们只触及了可以使用这些模式翻译的查询类型的表面。请查看以下资源以了解更深入的信息:
- Docs: What’s New in EF Core 8: Primitive collections
- YouTube: .NET Conf 2023 – Entity Framework Core 8: Improved JSON, queryable collections, and more…
- YouTube: .NET Data Community Standup – Collections of primitive values in EF Core
To learn more about EF Core 8, see Entity Framework Core 8 (EF Core 8) is available today announcement post.