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(typeof(BloggingContext).GetMethod(nameof(ActivePostCountForBlog), [typeof(int)]))
.HasName("CommentedPostCountForBlog");
기본적으로 EF Core는 CLR 함수를 동일한 이름의 사용자 정의 함수에 매핑하려고 합니다. 이름이 다른 경우 매핑하려는 사용자 정의 함수에 대한 올바른 이름을 제공하는 데 사용할 HasName 수 있습니다.
이제 다음 쿼리를 실행합니다.
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
메서드를 사용자 지정 SQL에 매핑
또한 EF Core는 특정 SQL로 변환되는 사용자 정의 함수를 허용합니다. 사용자 정의 함수 구성 중에 HasTranslation 메서드를 사용하여 SQL 식이 제공됩니다.
아래 예제에서는 두 정수 간의 백분율 차이를 계산하는 함수를 만듭니다.
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]
인수에 따라 사용자 정의 함수의 null 허용 여부 구성
사용자 정의 함수가 하나 이상의 인수가 null일 때만 null을(를) 반환할 수 있는 경우, EFCore는 이를 지정하여 더 성능이 우수한 SQL을 생성할 수 있는 방법을 제공합니다. 관련 함수 매개 변수 모델 구성에 PropagatesNullability() 호출을 추가하여 수행할 수 있습니다.
이를 설명하기 위해 사용자 함수 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();
});
첫 번째 함수는 표준 방식으로 구성됩니다. 두 번째 함수는 null 허용 여부 전파 최적화를 활용하도록 구성되어 null 매개 변수를 중심으로 함수가 작동하는 방식에 대한 자세한 정보를 제공합니다.
다음 쿼리를 실행하는 경우:
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인 경우에만 null을 반환할 수 있을 때에만 사용해야 합니다.
쿼리 가능한 함수를 테이블 반환 함수에 매핑
또한 EF Core는 엔터티 형식을 반환하는 사용자 정의 CLR 메서드를 사용하여 테이블 값 함수(TVF)에 매핑을 IQueryable 지원하므로, 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]
.NET