ユーザー定義関数のマッピング

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に基づいて、特定のブログに対して少なくとも 1 つのコメントを含む投稿の数を返す、ユーザー定義関数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のラムダ オーバーロードでは、MethodInfoを手動で参照する必要がなくなります。 default引数の値は、メソッドを識別するためにのみ使用されます。データベースに送信されることはありません。

既定では、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で関数を登録する代わりに、DbContextで宣言された静的メソッドは、DbFunctionAttributeを適用することで直接マップできます。 属性の NameSchemaIsBuiltIn、および IsNullable のプロパティは、データベース関数の対応する特性を構成します。これらは、 HasNameHasSchemaIsBuiltIn、および IsNullable fluent API メソッドが HasDbFunctionを使用するときに構成したのと同じ特性です。 コンテキストの属性付きメソッドが自動的に検出され、登録されます。他のクラスの属性付きメソッドは、引き続き HasDbFunctionに登録する必要があります。 以下のストア型の例のように、追加の Fluent 構成のためにビルダーが必要な場合にのみ、自動的に登録されるメソッドに対して HasDbFunction を呼び出します。

たとえば、次のメソッドでは、DbFunctionAttributeを使用してSQL Serverの組み込みのJSON_VALUE関数をマップします。 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 を使用して提供されます。

次の例では、2 つの整数の差の割合を計算する関数を作成します。

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]

Caution

HasTranslation は、SQL テキストではなく SQL 式ツリーで動作します。 変換では、正しい型マッピング、null 許容、および引数の null 許容伝達を使用して、有効な SqlExpression オブジェクトを構築する必要があります。 メタデータが正しくないと、無効な SQL または不適切なクエリ結果が生成される可能性があり、翻訳で使用される式の種類はデータベース プロバイダーに固有である可能性があります。 この低レベル API は、プロバイダーの SQL 式ツリーを理解した後にのみ使用します。可能な場合は、通常の関数マッピングまたは既存のプロバイダー変換を使用します。

引数に基づくユーザー定義関数の null 許容の構成

null 値の許容が関数引数から伝達される場合(つまり、その引数がnullされるたびに関数がnullを返す)、EF Core はより効率的な SQL を生成できます。 これを構成するには、関連するパラメーターの PropagatesNullability を呼び出します。 EF Core が SQL の 3 値ロジックを補正する方法の詳細については、「 クエリの null セマンティクス」を参照してください。

これを説明するために、ユーザー関数の ConcatStringsを定義します。

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

およびそれにマップされる 2 つの 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();
    });

最初の関数は標準の方法で構成されます。 2 番目の関数は、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)

2 番目のクエリでは、null 値の許容をテストするために関数自体を再評価する必要はありません。

関数が null を返す可能性がある理由が、構成された 1 つ以上のパラメーターが null であることのみにある場合にのみ、null 許容性の伝播を構成してください。

クエリ可能な関数をテーブル値関数にマッピングする

EF Core では、エンティティ型の IQueryable を返すユーザー定義 CLR メソッドを使用したテーブル値関数へのマッピングもサポートされており、EF Core は TVF をパラメーターにマップできます。 このプロセスは、スカラー ユーザー定義関数を SQL 関数にマッピングすることと似ています。データベースには TVF、LINQ クエリで使用される CLR 関数、2 つの間のマッピングが必要です。

たとえば、特定の "いいね" しきい値を満たすコメントが少なくとも 1 つ含まれるすべての投稿を返すテーブル値関数を使用します。

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));

ヒント

CLR 関数本体の FromExpression 呼び出しを使用すると、通常の 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]