使用者定義函數對應

EF Core 允許在查詢中使用使用者自訂的 SQL 函式。 為此,函式需在模型配置時映射到 CLR 方法。 當將 LINQ 查詢轉換成 SQL 時,會呼叫使用者自訂函式,而非它被映射到的 CLR 函式。

將方法映射到 SQL 函式

為了說明使用者定義函式映射的運作方式,讓我們定義以下實體:

public class Blog
{
    public int BlogId { get; set; }
    public string Url { get; set; }
    public int? Rating { get; set; }

    public List<Post> Posts { get; set; }
}

public class Post
{
    public int PostId { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }
    public int Rating { get; set; }
    public int BlogId { get; set; }

    public Blog Blog { get; set; }
    public List<Comment> Comments { get; set; }
}

public class Comment
{
    public int CommentId { get; set; }
    public string Text { get; set; }
    public int Likes { get; set; }
    public int PostId { get; set; }

    public Post Post { get; set; }
}

以及以下模型配置:

modelBuilder.Entity<Blog>()
    .HasMany(b => b.Posts)
    .WithOne(p => p.Blog);

modelBuilder.Entity<Post>()
    .HasMany(p => p.Comments)
    .WithOne(c => c.Post);

部落格可以有很多文章,每篇文章也可以有很多留言。

接著,建立使用者自訂函數 CommentedPostCountForBlog,根據該部落格 Id回傳至少有一則留言的貼文數量:

CREATE FUNCTION dbo.CommentedPostCountForBlog(@id int)
RETURNS int
AS
BEGIN
    RETURN (SELECT COUNT(*)
        FROM [Posts] AS [p]
        WHERE ([p].[BlogId] = @id) AND ((
            SELECT COUNT(*)
            FROM [Comments] AS [c]
            WHERE [p].[PostId] = [c].[PostId]) > 0));
END

為了在 EF Core 中使用此函式,我們定義以下 CLR 方法,並將其映射到使用者自訂函式:

public int ActivePostCountForBlog(int blogId)
    => throw new NotSupportedException();

CLR方法的正文並不重要。 除非 EF Core 無法轉換其參數,否則此方法不會在用戶端被調用。 如果參數可以被翻譯,EF Core 只關心方法簽名。

備註

在範例中,該方法定義於 DbContext,但它也可以定義為其他類別中的靜態方法。

此函數定義現在可與模型配置中的使用者定義函數關聯:

modelBuilder.HasDbFunction(() => ActivePostCountForBlog(default))
    .HasName("CommentedPostCountForBlog")
    .HasSchema("dbo");

HasDbFunction 的 Lambda 多載可避免手動查閱 MethodInfodefault參數值僅用於識別方法;它們從未送入資料庫。

預設情況下,EF Core 會將 CLR 方法映射到預設結構中同名的資料庫函式。 當名稱或結構描述不同時,請使用 HasNameHasSchema

現在,執行以下查詢:

var query1 = from b in context.Blogs
             where context.ActivePostCountForBlog(b.BlogId) > 1
             select b;

將產出這個 SQL:

SELECT [b].[BlogId], [b].[Rating], [b].[Url]
FROM [Blogs] AS [b]
WHERE [dbo].[CommentedPostCountForBlog]([b].[BlogId]) > 1

將方法映射到內建函式

EF Core 預設會將對應的函式視為使用者定義函式。 有些資料庫在產生 SQL 時會區分內建函式與使用者自訂函式。 例如,SQL Server 要求使用者定義函式必須符合結構標準,但內建函式則不符合結構標準。

使用 IsBuiltIn 將 CLR 方法對應到內建函式:

public static int IsDate(string value)
    => throw new NotSupportedException();
modelBuilder.HasDbFunction(typeof(BloggingContext).GetMethod(nameof(IsDate), [typeof(string)]))
    .HasName("ISDATE")
    .IsBuiltIn();

IsBuiltIn屬性在使用屬性時提供相同的配置:

[DbFunction(Name = "ISDATE", IsBuiltIn = true)]

使用 DbFunctionAttribute 映射函式

與其在 OnModelCreating 中註冊函式,不如直接透過套用 DbFunctionAttribute 來對應在 DbContext 上宣告的靜態方法。 此屬性的 NameSchemaHasDbFunctionHasName 屬性會設定資料庫函式的對應特性;這些特性與使用 IsBuiltIn 時,由 HasSchemaIsBuiltInIsNullableIsNullable Fluent API 方法所設定的特性相同。 上下文中已歸屬的方法會自動被發現並註冊;其他類別上的歸屬性方法仍必須註冊於 HasDbFunction。 只有在需要建置器以進行額外流暢配置時,才會呼叫 HasDbFunction 自動註冊的方法,如下方的儲存類型範例。

例如,以下方法用DbFunctionAttribute來映射 JSON_VALUE SQL Server 內建函式。 因為 IsBuiltIntrue,EF Core 會直接發出函式名稱,卻沒有結構。

[DbFunction(Name = "JSON_VALUE", IsBuiltIn = true, IsNullable = true)]
public static string JsonValue(Dictionary<string, string> json, string path)
    => throw new NotSupportedException();

配置商店類型

可使用 HasStoreType 來設定函式的回傳儲存類型及 HasStoreType 參數的儲存類型。 當 CLR 參數類型沒有原生資料庫映射時,這特別有用。

在此範例中, JsonEntity.Metadata 是透過值轉換器儲存的 nvarchar(max) 字典。 json 函式參數具有相同的儲存類型,而結果則使用由 JSON_VALUE 傳回的 nvarchar(4000) 類型:

modelBuilder.Entity<JsonEntity>()
    .Property(e => e.Metadata)
    .HasConversion(
        value => JsonSerializer.Serialize(value, (JsonSerializerOptions)null),
        value => JsonSerializer.Deserialize<Dictionary<string, string>>(value, (JsonSerializerOptions)null),
        new ValueComparer<Dictionary<string, string>>(
            (c1, c2) => c1.Count == c2.Count && !c1.Except(c2).Any(),
            c => c.Aggregate(0, (a, kvp) => a ^ HashCode.Combine(kvp.Key, kvp.Value)),
            c => c.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)));

var jsonValueFunction = modelBuilder.HasDbFunction(() => JsonValue(default, default));
jsonValueFunction.HasStoreType("nvarchar(4000)");
jsonValueFunction.HasParameter("json").HasStoreType("nvarchar(max)");

此函數可與轉換後的性質一起使用:

var jsonQuery = context.JsonEntities.Select(e => BloggingContext.JsonValue(e.Metadata, "$.Filter"));
SELECT JSON_VALUE([j].[Metadata], N'$.Filter')
FROM [JsonEntities] AS [j]

值轉換器取自作為函數參數傳遞的表達式。 因此,此模式適用於像 JsonEntity.Metadata這樣的映射屬性,但配置參數儲存型別並不會使任意字典值可轉換。 若要使用記憶體中的字典,先將其序列化,並將所得字串傳給一個獨立映射的方法,該方法的 CLR 參數為 string

將方法映射到自訂 SQL

EF Core 也允許將 CLR 方法直接轉譯成 SQL 表達式,而非資料庫函式。 SQL 運算式是在函式設定期間使用 HasTranslation 提供的。

在下面的範例中,我們將建立一個函數,計算兩個整數之間的百分比差異。

CLR方法如下:

public double PercentageDifference(double first, int second)
    => throw new NotSupportedException();

函數定義如下:

// 100 * ABS(first - second) / ((first + second) / 2)
modelBuilder.HasDbFunction(
        typeof(BloggingContext).GetMethod(nameof(PercentageDifference), [typeof(double), typeof(int)]))
    .HasTranslation(
        args =>
            new SqlBinaryExpression(
                ExpressionType.Multiply,
                new SqlConstantExpression(100, new IntTypeMapping("int", DbType.Int32)),
                new SqlBinaryExpression(
                    ExpressionType.Divide,
                    new SqlFunctionExpression(
                        "ABS",
                        [
                            new SqlBinaryExpression(
                                ExpressionType.Subtract,
                                args.First(),
                                args.Skip(1).First(),
                                args.First().Type,
                                args.First().TypeMapping)
                        ],
                        nullable: true,
                        argumentsPropagateNullability: [true, true],
                        type: args.First().Type,
                        typeMapping: args.First().TypeMapping),
                    new SqlBinaryExpression(
                        ExpressionType.Divide,
                        new SqlBinaryExpression(
                            ExpressionType.Add,
                            args.First(),
                            args.Skip(1).First(),
                            args.First().Type,
                            args.First().TypeMapping),
                        new SqlConstantExpression(2, new IntTypeMapping("int", DbType.Int32)),
                        args.First().Type,
                        args.First().TypeMapping),
                    args.First().Type,
                    args.First().TypeMapping),
                args.First().Type,
                args.First().TypeMapping));

一旦定義了函式,就可以在查詢中使用。 EF Core 不會呼叫資料庫函式,而是根據 HasTranslation 建構的 SQL 表達式樹,直接將方法主體轉譯成 SQL。 以下 LINQ 查詢:

var query2 = from p in context.Posts
             select context.PercentageDifference(p.BlogId, 3);

產生下列 SQL:

SELECT 100 * (ABS(CAST([p].[BlogId] AS float) - 3) / ((CAST([p].[BlogId] AS float) + 3) / 2))
FROM [Posts] AS [p]

注意事項

HasTranslation 是用 SQL 表達式樹,不是 SQL 文字。 該轉譯必須建構出有效的 SqlExpression 物件,並具有正確的型別對應、可空性,以及引數可空性的傳播。 錯誤的元資料可能導致無效的 SQL 或錯誤的查詢結果,且翻譯所使用的表達式類型可能專屬於某個資料庫提供者。 只有在了解提供者的 SQL 表達式樹後,才使用此底層 API;在可能的情況下,偏好一般函數映射或現有提供者的轉換。

根據函數參數配置使用者定義函式的可空性

如果可為 Null 的特性會從函式引數傳播——也就是說,只要該引數是 null,函式就會回傳 null——EF Core 就能產生效率更高的 SQL。 透過呼叫 PropagatesNullability 並指定相關參數來進行設定。 欲了解更多關於 EF Core 如何補償 SQL 三值邏輯的資訊,請參閱 查詢空語意

為說明此點,定義使用者函式 ConcatStrings

CREATE FUNCTION [dbo].[ConcatStrings] (@prm1 nvarchar(max), @prm2 nvarchar(max))
RETURNS nvarchar(max)
AS
BEGIN
    RETURN @prm1 + @prm2;
END

以及兩種對應到它的 CLR 方法:

public string ConcatStrings(string prm1, string prm2)
    => throw new InvalidOperationException();

public string ConcatStringsOptimized(string prm1, string prm2)
    => throw new InvalidOperationException();

模型配置(在 OnModelCreating 方法內)如下:

modelBuilder
    .HasDbFunction(typeof(BloggingContext).GetMethod(nameof(ConcatStrings), [typeof(string), typeof(string)]))
    .HasName("ConcatStrings");

modelBuilder.HasDbFunction(
    typeof(BloggingContext).GetMethod(nameof(ConcatStringsOptimized), [typeof(string), typeof(string)]),
    b =>
    {
        b.HasName("ConcatStrings");
        b.HasParameter("prm1").PropagatesNullability();
        b.HasParameter("prm2").PropagatesNullability();
    });

第一個功能是以標準方式配置的。 第二個函數則被設定為利用空可傳播性最佳化,提供更多關於函數在空參數周圍行為的資訊。

在發出以下查詢時:

var query3 = context.Blogs.Where(e => context.ConcatStrings(e.Url, e.Rating.ToString()) != "https://mytravelblog.com/4");
var query4 = context.Blogs.Where(
    e => context.ConcatStringsOptimized(e.Url, e.Rating.ToString()) != "https://mytravelblog.com/4");

我們得到這個 SQL:

SELECT [b].[BlogId], [b].[Rating], [b].[Url]
FROM [Blogs] AS [b]
WHERE ([dbo].[ConcatStrings]([b].[Url], CONVERT(VARCHAR(11), [b].[Rating])) <> N'Lorem ipsum...') OR [dbo].[ConcatStrings]([b].[Url], CONVERT(VARCHAR(11), [b].[Rating])) IS NULL

SELECT [b].[BlogId], [b].[Rating], [b].[Url]
FROM [Blogs] AS [b]
WHERE ([dbo].[ConcatStrings]([b].[Url], CONVERT(VARCHAR(11), [b].[Rating])) <> N'Lorem ipsum...') OR ([b].[Url] IS NULL OR [b].[Rating] IS NULL)

第二個查詢不需要重新評估函式本身來測試其空性。

備註

只有當函式能夠回傳 null ,因為一個或多個配置參數為 null時,才會配置空可傳播性。

將可查詢函式映射到資料表值函式

EF Core 也支援使用使用者自訂的 CLR 方法來映射到表值函數,該方法返回實體類型的集合,讓 EF Core 能將帶有參數的 TVF 進行映射。 這個過程類似於將純量使用者定義函式映射到 SQL 函式:我們需要資料庫中的 TVF、用於 LINQ 查詢的 CLR 函式,以及兩者之間的映射。

舉例來說,我們將使用一個表值函數,回傳所有至少有一則留言符合「按讚」門檻的貼文:

CREATE FUNCTION dbo.PostsWithPopularComments(@likeThreshold int)
RETURNS TABLE
AS
RETURN
(
    SELECT [p].[PostId], [p].[BlogId], [p].[Content], [p].[Rating], [p].[Title]
    FROM [Posts] AS [p]
    WHERE (
        SELECT COUNT(*)
        FROM [Comments] AS [c]
        WHERE ([p].[PostId] = [c].[PostId]) AND ([c].[Likes] >= @likeThreshold)) > 0
)

CLR 方法簽名如下:

public IQueryable<Post> PostsWithPopularComments(int likeThreshold)
    => FromExpression(() => PostsWithPopularComments(likeThreshold));

小提示

FromExpression CLR 函式體中的呼叫允許使用該函式取代一般的 DbSet。

以下是地圖:

modelBuilder.Entity<Post>().ToTable("Posts");
modelBuilder.HasDbFunction(typeof(BloggingContext).GetMethod(nameof(PostsWithPopularComments), [typeof(int)]));

備註

可查詢函式必須映射到一個有資料表值的函式。 HasTranslation 僅支援純量函式,無法用於表值函式。

當函式被映射時,會進行以下查詢:

var likeThreshold = 3;
var query5 = from p in context.PostsWithPopularComments(likeThreshold)
             orderby p.Rating
             select p;

產出:

SELECT [p].[PostId], [p].[BlogId], [p].[Content], [p].[Rating], [p].[Title]
FROM [dbo].[PostsWithPopularComments](@likeThreshold) AS [p]
ORDER BY [p].[Rating]