EF Coreでソートキーのセレクタを共通化する

ググれば解決方法が見つかると思ったが、上手く検索するワードが思いつかず案外ハマってしまったのでメモ。
確認はEF Coreで行いましたが、Entity Frameworkでも使えると思います。

# 環境

- .NET 8.0
- EF Core 8.0 Code First
    - In-Memory データベース

# やりたいこと

EF Coreで以下のようなエンティティがあった場合に、並び替えに使用するキーを取得するセレクタを共通化したい。

```csharp
public class BloggingContext : DbContext
{
    public DbSet<Blog> Blogs { get; set; }
    public DbSet<Post> Posts { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseInMemoryDatabase("TestDatabase");
    }
}

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

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

    public int BlogId { get; set; }
}
```

例えば、ブログの評価（`Blog.Rating`）が5段階だったとして、「評価の降順で並び替えるが、評価が3以下のブログは同列として扱う」といった条件の場合、LINQ to Entitiesでは以下のように記述できる。

```csharp
var blogs = bloggingContext.Blogs
    .OrderByDescending(b => b.Rating > 3 ? b.Rating : 1);
```

この並び替えが様々な処理で必要だった場合に毎回これを記述するのはアレなので、`b.Rating > 3 ? b.Rating : 1`という式だけ抜き出して共通化しておきたい。

# 試したこと

### 案1. 並び替えの拡張メソッドを定義する

まず思い浮かんだのが拡張メソッドだが、LINQ to Objectsでも並び替えを使用したい場合や、`ThenBy()`で指定したい場合を考えると、いくつも拡張メソッドを定義する必要が出てきてしまう。

```csharp
public static class IQueryableExtensions
{
    public static IOrderedQueryable<Blog> OrderByRating(this IQueryable<Blog> blogs)
    {
        return blogs.OrderByDescending(b => b.Rating > 3 ? b.Rating : 1);
    }

    public static IOrderedQueryable<Blog> ThenByRating(this IOrderedQueryable<Blog> blogs)
    {
        ...
    }
}
public static class IEnumerableExtensions
{
    public static IOrderedEnumerable<Blog> OrderByRating(this IEnumerable<Blog> blogs)
    {
        ...
    }

    public static IOrderedEnumerable<Blog> ThenByRating(this IOrderedEnumerable<Blog> blogs)
    {
        ...
    }
}

// 第2ソートキーで使いたい
var blogs = bloggingContext.Blogs
    .OrderBy(b => b.BlogId)
    .ThenByRating();

// LINQ to Objectsでも使いたい
var blogObjects = new[] { blog1, blog2 };
var orderedBlogObjects = blogs
    .OrderByRating();
```

### 案2. キーを取得するセレクタを抜き出す

それならばキーを取得するセレクタの式木をフィールド等に抜き出せば良いのでは？とも考えたが、別のテーブルを結合して別の型に射影する等のクエリで使えない。[^1]

```csharp
public static readonly Expression<Func<Blog, int>> BlogRatingOrderingKeySelector =
    b => b.Rating > 3 ? b.Rating : 1;

// Blogのみを取得するのであれば使えるが
var blogs = bloggingContext.Blogs
    .OrderByDescending(BlogRatingOrderingKeySelector);

// 別の型(匿名型など)に射影して取得する場合は使えない
var blogPosts = bloggingContext.Posts
    .Join(bloggingContext.Blogs, p => p.BlogId, b => b.BlogId, (p, b) => new { Post = p, Blog = b })
    .OrderByDescending(BlogRatingOrderingKeySelector); // エラー
```

任意の型からBlogを選択するセレクタを引数にすることも考えたが、型の指定が必須となってしまい、匿名型には適用できなかった。

また、式木の組み立てが複雑になってしまうというデメリットもある。

```csharp
public static Expression<Func<T, int>> CreateBlogRatingOrderingKeySelector<T>(Expression<Func<T, Blog>> blogSelector)
{
    // ぱっと見て何やっているのか分からない…
    var parameter = Expression.Parameter(typeof(T));
    var body = Expression.Condition(
        Expression.GreaterThan(
            Expression.Property(blogSelector.Body, nameof(Blog.Rating)),
            Expression.Constant(3)
        ),
        Expression.Property(blogSelector.Body, nameof(Blog.Rating)),
        Expression.Constant(1)
    );

    return Expression.Lambda<Func<T, int>>(body, parameter);
}

var blogPosts = bloggingContext.Posts
    .Join(bloggingContext.Blogs, p => p.BlogId, b => b.BlogId, (p, b) => new { Post = p, Blog = b })
    .OrderByDescending(CreateBlogRatingOrderingKeySelector<{ここで型の指定が必須になっていまう}>(bp => bp.Blog))
```

# 解決策

[LINQKit](https://github.com/scottksmith95/LINQKit)の`Invoke()`メソッドで式木を結合してやれば実現できた。

まずLINQKitのパッケージを参照に追加する。

```sh
dotnet add package LinqKit.Microsoft.EntityFrameworkCore --version 8.1.5
```

次にキーを取得するセレクタの式木のフィールド/プロパティを定義する。

```csharp
public static readonly Expression<Func<Blog, int>> BlogRatingOrderingKeySelector =
    b => b.Rating > 3 ? b.Rating : 1;
```

後は`OrderBy()`/`ThenBy()`のセレクタ内で定義した式木を`Invoke()`で呼び出すと

- LINQ to Objectsの場合：式木がコンパイルされて実行される
- LINQ to Entitiesの場合：式木がSQLに変換されて実行される（ただし`AsExpandable()`の呼び出しが必要）

という動きとなる。

```csharp
// LINQ to Objects
var blogObjects = new[] { blog1, blog2 };
var orderedBlogObjects = blogs
    .OrderByDescending(b => BlogRatingOrderingKeySelector.Invoke(b));

// LINQ to Entities
var blogs = bloggingContext.Blogs
    .AsExpandable() // 式木を結合するためにAsExpandable()の呼び出しが必要
    .OrderByDescending(b => b.BlogId)
    .ThenBy(b => BlogRatingOrderingKeySelector.Invoke(b));
```

なお、毎回`AsExpandable()`の呼び出しを行うのが面倒な場合は、データコンテキストのセットアップ時にまとめて指定することも可能。

```csharp
public class BloggingContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder
            .UseInMemoryDatabase("TestDatabase")
            .WithExpressionExpanding(); // この行を追加
    }
}
```

[^1]: この例だとナビゲーションプロパティを定義してやれば解決するが、DDD+CQRSで別の集約同士を結合したい場合などを想定