グローバル クエリ フィルター

グローバル クエリ フィルターは、(通常 OnModelCreating にある) メタデータ モデルのエンティティ型に適用される LINQ クエリ述語です。 クエリ述語は、通常、LINQ の Where クエリ演算子に渡されるブール式です。 このようなフィルターは、EF Core によって、これらのエンティティ型に関係する LINQ クエリに自動的に適用されます。 また、EF Core によって、Include またはナビゲーション プロパティを使用して間接的に参照されるエンティティ型にも適用されます。 この機能の一般的な用途は次のようになっています。

  • 論理的な削除 - エンティティ型が IsDeleted プロパティを定義します。
  • マルチテナント - エンティティ型が TenantId プロパティを定義します。

次の例では、グローバル クエリ フィルターを使用して、マルチテナントおよび論理的な削除のクエリ動作を単純なブログ モデルに実装する方法を示しています。

ヒント

この記事のサンプルは GitHub で確認できます。

Note

ここでは、簡単な例としてマルチテナントを使います。 また、EF Core アプリケーションのマルチテナントに関する包括的なガイダンスの記事も用意されています。

最初に、エンティティを次のように定義します。

public class Blog
{
#pragma warning disable IDE0051, CS0169 // Remove unused private members
    private string _tenantId;
#pragma warning restore IDE0051, CS0169 // Remove unused private members

    public int BlogId { get; set; }
    public string Name { get; set; }
    public string Url { 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 bool IsDeleted { get; set; }

    public Blog Blog { get; set; }
}

Blog エンティティの _tenantId フィールドの宣言に着目してください。 このフィールドは、各 Blog インスタンスを固有のテナントに関連付けるために使用されます。 また、Post エンティティ型で IsDeleted プロパティが定期されています。 このプロパティは、Post インスタンスが "論理的に削除" されたかどうかを追跡するために使用されます。 つまり、基になるデータを物理的に削除せずに、インスタンスは削除済みとしてマークされます。

次に、HasQueryFilter API を使用して OnModelCreating でクエリ フィルターを構成します。

modelBuilder.Entity<Blog>().HasQueryFilter(b => EF.Property<string>(b, "_tenantId") == _tenantId);
modelBuilder.Entity<Post>().HasQueryFilter(p => !p.IsDeleted);

HasQueryFilter の呼び出しに渡される predicate 式は、これらの型の LINQ クエリに自動的に適用されます。

ヒント

DbContext インスタンス レベルのフィールドの使用に注意してください。_tenantId は、現在のテナントを設定するために使用されます。 モデルレベル フィルターは、正しいコンテキスト インスタンス (つまり、クエリを実行しているインスタンス) の値を使用します。

Note

現在、同じエンティティに対して複数のクエリ フィルターを定義することはできません。最後のフィルターのみが適用されます。 ただし、AND 論理演算子 (C# では &&を使用) を使用して、複数の条件を設定した単一のフィルターを定義できます

ナビゲーションの使用

グローバル クエリ フィルターを定義するときにも、ナビゲーションを使用することができます。 クエリ フィルターでナビゲーションを使用すると、クエリ フィルターが再帰的に適用されます。 クエリ フィルターで使用されているナビゲーションが EF Core によって拡張されると、参照先エンティティで定義されているクエリ フィルターも適用されます。

これを説明するために、次のように OnModelCreating でクエリ フィルターを構成します。

modelBuilder.Entity<Blog>().HasMany(b => b.Posts).WithOne(p => p.Blog);
modelBuilder.Entity<Blog>().HasQueryFilter(b => b.Posts.Count > 0);
modelBuilder.Entity<Post>().HasQueryFilter(p => p.Title.Contains("fish"));

次に、すべての Blog エンティティに対してクエリを実行します。

var filteredBlogs = db.Blogs.ToList();

このクエリでは、次の SQL が生成されます。これにより、BlogPost の両方のエンティティに定義されたクエリ フィルターが適用されます。

SELECT [b].[BlogId], [b].[Name], [b].[Url]
FROM [Blogs] AS [b]
WHERE (
    SELECT COUNT(*)
    FROM [Posts] AS [p]
    WHERE ([p].[Title] LIKE N'%fish%') AND ([b].[BlogId] = [p].[BlogId])) > 0

Note

現在、グローバル クエリ フィルター定義内のサイクルは EF Core によって検出されないため、それらを定義する際には注意が必要です。 正しく指定されていない場合は、クエリの変換中にサイクルが無限ループになる可能性があります。

必須のナビゲーションを使用したクエリ フィルターを含むエンティティへのアクセス

注意事項

グローバル クエリ フィルターが定義されているエンティティにアクセスする場合に必須のナビゲーションを使用すると、予期しない結果が生じる可能性があります。

必須のナビゲーションでは、関連エンティティが常に存在している必要があります。 必要な関連エンティティがクエリ フィルターによって除外されると、親エンティティも結果に存在しなくなります。 そのため、結果で取得できる要素が予想よりも少ない可能性があります。

この問題を説明するために、上で指定した Blog エンティティおよび Post エンティティと、次の OnModelCreating メソッドを使用できます。

modelBuilder.Entity<Blog>().HasMany(b => b.Posts).WithOne(p => p.Blog).IsRequired();
modelBuilder.Entity<Blog>().HasQueryFilter(b => b.Url.Contains("fish"));

このモデルは、次のデータを使用してシード処理することができます。

db.Blogs.Add(
    new Blog
    {
        Url = "http://sample.com/blogs/fish",
        Posts = new List<Post>
        {
            new Post { Title = "Fish care 101" },
            new Post { Title = "Caring for tropical fish" },
            new Post { Title = "Types of ornamental fish" }
        }
    });

db.Blogs.Add(
    new Blog
    {
        Url = "http://sample.com/blogs/cats",
        Posts = new List<Post>
        {
            new Post { Title = "Cat care 101" },
            new Post { Title = "Caring for tropical cats" },
            new Post { Title = "Types of ornamental cats" }
        }
    });

次の 2 つのクエリを実行すると、問題が発生する可能性があります。

var allPosts = db.Posts.ToList();
var allPostsWithBlogsIncluded = db.Posts.Include(p => p.Blog).ToList();

上記の設定の場合、最初のクエリでは 6 つの Post がすべて返されますが、2 つめのクエリで返されるのは 3 つのみです。 このような不一致は、2 つめのクエリ内の Include メソッドによって、関連する Blog エンティティが読み込まれるために発生します。 BlogPost 間のナビゲーションは必須であるため、EF Core ではクエリの作成時に INNER JOIN が使用されます。

SELECT [p].[PostId], [p].[BlogId], [p].[Content], [p].[IsDeleted], [p].[Title], [t].[BlogId], [t].[Name], [t].[Url]
FROM [Posts] AS [p]
INNER JOIN (
    SELECT [b].[BlogId], [b].[Name], [b].[Url]
    FROM [Blogs] AS [b]
    WHERE [b].[Url] LIKE N'%fish%'
) AS [t] ON [p].[BlogId] = [t].[BlogId]

INNER JOIN を使用すると、関連する Blog がグローバル クエリ フィルターによって削除されたすべての Post が除外されます。

そのアドレス指定は、必須ではなく、オプションのナビゲーションを使用して行うことができます。 このように、最初のクエリは以前と同じままですが、2 番目のクエリでは LEFT JOIN が生成され、6 個の結果が返されるようになります。

modelBuilder.Entity<Blog>().HasMany(b => b.Posts).WithOne(p => p.Blog).IsRequired(false);
modelBuilder.Entity<Blog>().HasQueryFilter(b => b.Url.Contains("fish"));

別の方法として、BlogPost の両方のエンティティに対して一貫したフィルターを指定する方法があります。 これにより、一致するフィルターが BlogPost の両方に適用されます。 予期しない状態になる可能性がある Post は削除され、両方のクエリとも 3 個の結果が返されます。

modelBuilder.Entity<Blog>().HasMany(b => b.Posts).WithOne(p => p.Blog).IsRequired();
modelBuilder.Entity<Blog>().HasQueryFilter(b => b.Url.Contains("fish"));
modelBuilder.Entity<Post>().HasQueryFilter(p => p.Blog.Url.Contains("fish"));

フィルターを無効にする

フィルターは、IgnoreQueryFilters 演算子を使用することで、個々の LINQ クエリに対して無効にできます。

blogs = db.Blogs
    .Include(b => b.Posts)
    .IgnoreQueryFilters()
    .ToList();

制限事項

グローバル クエリ フィルターには、次の制限があります。

  • フィルターは、継承階層のルート エンティティ型に対してのみ定義できます。