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 多載可避免手動查閱 MethodInfo。
default參數值僅用於識別方法;它們從未送入資料庫。
預設情況下,EF Core 會將 CLR 方法映射到預設結構中同名的資料庫函式。 當名稱或結構描述不同時,請使用 HasName 和 HasSchema。
現在,執行以下查詢:
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 上宣告的靜態方法。 此屬性的 Name、Schema、HasDbFunction 及 HasName 屬性會設定資料庫函式的對應特性;這些特性與使用 IsBuiltIn 時,由 HasSchema、IsBuiltIn、IsNullable 及 IsNullable Fluent API 方法所設定的特性相同。 上下文中已歸屬的方法會自動被發現並註冊;其他類別上的歸屬性方法仍必須註冊於 HasDbFunction。 只有在需要建置器以進行額外流暢配置時,才會呼叫 HasDbFunction 自動註冊的方法,如下方的儲存類型範例。
例如,以下方法用DbFunctionAttribute來映射 JSON_VALUE SQL Server 內建函式。 因為 IsBuiltIn 是 true,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]