【C# EntityFramework】プロパティ名で指定するOrderByの作り方-続き【拡張メソッド・IEnumerable編】

技術

プロパティ名版OrderByを拡張メソッド化

前回、LINQ to EntiiesのOrderByを項目名で使いたい、という不思議な相談を受けて、調べて見たらできたよ、って感じの記事を書きました。

実はこの話にはもう少し続きがあります。

まぁ、そんなにややこしい話でもないんだけど。
なにやら前回作ったコードを少し汎用化して、使い回せるようにしたいんだって。

そんなわけで、拡張メソッドにして使いやすくしてみました。

まずは前回作ったコードを再掲します。

using EfSampleApp.Models;
using System.Linq;
using System.Linq.Expressions;

static IEnumerable<User> Select(SampleDbContext context, int offset, int count, string orderName, string orderDirection)
{
    var entityType = typeof(User);
    var query = context.Users
        .Where(user => !user.DeleteFlag);

    // 「user => user.Age」という式ツリーを生成
    var arg = Expression.Parameter(entityType, "user");
    var property = Expression.Property(arg, orderName);
    var selector = Expression.Lambda(property, new ParameterExpression[] { arg });

    // System.Linq.Queryable.OrderBy または OrderByDescending メソッドを
    // リフレクションで取得
    var direction = orderDirection != "asc" ? "OrderByDescending" : "OrderBy";
    var propertyInfo = entityType.GetProperty(orderName);
    var enumarableType = typeof(Queryable);
    var method = enumarableType.GetMethods()
         .Where(m => m.Name == direction && m.IsGenericMethodDefinition)
         .Where(m =>
         {
            var parameters = m.GetParameters().ToList();
            return parameters.Count == 2;
         }).Single();
    
    var genericMethod = method
         .MakeGenericMethod(entityType, propertyInfo.PropertyType);

    // OrderBy または OrderByDescending メソッドを実行
    var newQuery = (IOrderedQueryable<User>)genericMethod
         .Invoke(genericMethod, new object[] { query, selector });

    return newQuery.Skip(offset).Take(count);
}

var dbContext = new SampleDbContext();
var query = Select(dbContext, 3, 3, nameof(User.Age), "asc");
foreach (var user in query)
{
    Console.WriteLine($"id[{user.Id}],name[{user.Name}],age[{user.Age}]");
}
Console.WriteLine("===================");

ここからOrderByを作っている部分を抜き出してジェネリクスを使って抽象化します。
さらに本家(System.Linq.dll)に倣って4つの拡張メソッドにしました。

using System.Linq;
using System.Linq.Expressions;

namespace EfSampleApp.Models;

public static class QueryableExtensions
{
    public static IOrderedQueryable<T> OrderBy<T>(this IQueryable<T> query, string propertyName)
    {
        return OrderByCustom(query, propertyName, "OrderBy");
    }

    public static IOrderedQueryable<T> OrderByDescending<T>(this IQueryable<T> query, string propertyName)
    {
        return OrderByCustom(query, propertyName, "OrderByDescending");
    }

    public static IOrderedQueryable<T> ThenBy<T>(this IOrderedQueryable<T> query, string propertyName)
    {
        return OrderByCustom(query, propertyName, "ThenBy");
    }

    public static IOrderedQueryable<T> ThenByDescending<T>(this IOrderedQueryable<T> query, string propertyName)
    {
        return OrderByCustom(query, propertyName, "ThenByDescending");
    }

    private static IOrderedQueryable<T> OrderByCustom<T>(IQueryable<T> query, string propertyName, string orderByName)
    {
        var entityType = typeof(T);

        // 「x => x.{propertyName}」という式ツリーを生成
        var arg = Expression.Parameter(entityType, "x");
        var property = Expression.Property(arg, propertyName);
        var selector = Expression.Lambda(property, new ParameterExpression[] { arg });

        // System.Linq.Queryable.OrderBy メソッドをリフレクションで取得
        var propertyInfo = entityType.GetProperty(propertyName);
        var enumarableType = typeof(Queryable);
        var method = enumarableType.GetMethods()
            .Where(m => m.Name == orderByName && m.IsGenericMethodDefinition)
            .Where(m =>
            {
                var parameters = m.GetParameters().ToList();
                return parameters.Count == 2;
            }).Single();
        
        var genericMethod = method
            .MakeGenericMethod(entityType, propertyInfo.PropertyType);

        // OrderBy メソッドを実行
        return (IOrderedQueryable<T>)genericMethod
            .Invoke(genericMethod, new object[] { query, selector });
    }
}

ジェネリクスを使って抽象化したものはOrderByCustomというメソッドになりました。
Userクラス専用だったものをTという抽象化された型定義に変更することで汎用化しています。
こうすることで、例えば匿名クラスのOrderByなんかも可能になります。

それから、作った拡張メソッドは下記の4つです。

  • OrderBy
  • OrderByDescending
  • ThenBy
  • ThenByDescending

前回のコードではThenByThenByDescendingはありませんでしたが、汎用化するなら必要だろうということで作成しました。

ちなみにこのQueryableExtensionsクラスはModelsディレクトリの下に作成しています。

この拡張メソッドを使うように前回のコードを変更してみます。
ついでに複数の並び順を指定できるようにしてみました。

using EfSampleApp.Models;

static IEnumerable<User> Select(SampleDbContext context, int offset, int count, (string name, string direction)[] orders)
{
    var query = context.Users
        .Where(user => !user.DeleteFlag);
    // order by句を作成
    if (orders.Length > 0)
    {
        var orderedQuery = orders[0].direction == "asc" ?
            query.OrderBy(orders[0].name) :
            query.OrderByDescending(orders[0].name);
        for (var i = 1; i < orders.Length; i++)
        {
            orderedQuery = orders[i].direction == "asc" ?
                orderedQuery.ThenBy(orders[i].name) :
                orderedQuery.ThenByDescending(orders[i].name);
        }
        query = orderedQuery;
    }

    return query.Skip(offset).Take(count);
}

var dbContext = new SampleDbContext();
var query = Select(dbContext, 3, 3, new[] { (nameof(User.Age), "desc"), (nameof(User.Name), "asc") }); // 変更
foreach (var user in query)
{
    Console.WriteLine($"id[{user.Id}],name[{user.Name}],age[{user.Age}]");
}
Console.WriteLine("===================");

IEnumerable版が欲しい?

しばらくしてまた相談を受けました。

LINQ to Objectsでも使いたいんだけど、うまくいかなくて・・・。なんとかなる?

そんなわけで、こんな感じのコードを見せてくれました。

using System.Linq;
using System.Linq.Expressions;

namespace EfSampleApp.Models;

public static class EnumerableExtensions
{
    public static IOrderedEnumerable<T> OrderBy<T>(this IEnumerable<T> query, string propertyName)
    {
        return OrderByCustom(query, propertyName, "OrderBy");
    }

    public static IOrderedEnumerable<T> OrderByDescending<T>(this IEnumerable<T> query, string propertyName)
    {
        return OrderByCustom(query, propertyName, "OrderByDescending");
    }

    public static IOrderedEnumerable<T> ThenBy<T>(this IOrderedEnumerable<T> query, string propertyName)
    {
        return OrderByCustom(query, propertyName, "ThenBy");
    }

    public static IOrderedEnumerable<T> ThenByDescending<T>(this IOrderedEnumerable<T> query, string propertyName)
    {
        return OrderByCustom(query, propertyName, "ThenByDescending");
    }

    private static IOrderedEnumerable<T> OrderByCustom<T>(IEnumerable<T> query, string propertyName, string orderByName)
    {
        var entityType = typeof(T);

        // 「x => x.{propertyName}」という式ツリーを生成
        var arg = Expression.Parameter(entityType, "x");
        var property = Expression.Property(arg, propertyName);
        var selector = Expression.Lambda(property, new ParameterExpression[] { arg });

        // System.Linq.Enumerable.OrderBy メソッドをリフレクションで取得
        var propertyInfo = entityType.GetProperty(propertyName);
        var enumarableType = typeof(Enumerable);
        var method = enumarableType.GetMethods()
            .Where(m => m.Name == orderByName && m.IsGenericMethodDefinition)
            .Where(m =>
            {
                var parameters = m.GetParameters().ToList();
                return parameters.Count == 2;
            }).Single();
        
        var genericMethod = method
            .MakeGenericMethod(entityType, propertyInfo.PropertyType);

        // OrderBy メソッドを実行
        return (IOrderedEnumerable<T>)genericMethod
            .Invoke(genericMethod, new object[] { query, selector });
    }
}

先の拡張メソッドの実装をコピーして、IEnumerable版を作ってみたってことらしいんですが、実行してみると例外になります。

Unhandled exception. System.ArgumentException: Object of type ‘System.Linq.Expressions.Expression1`1[System.Func2[EfSampleApp.Models.User,System.Nullable`1[System.Int32]]]’ cannot be converted to type ‘System.Func`2[EfSampleApp.Models.User,System.Nullable`1[System.Int32]]’.

実はOrderByメソッドはIQueryableとIEnumerableでは引数の型が違います。

public static System.Linq.IOrderedQueryable<TSource> OrderBy<TSource,TKey> (
    this System.Linq.IQueryable<TSource> source,
    System.Linq.Expressions.Expression<Func<TSource,TKey>> keySelector);
Queryable.OrderBy メソッド (System.Linq)
シーケンスの要素を昇順に並べ替えます。
public static System.Linq.IOrderedEnumerable<TSource> OrderBy<TSource,TKey> (
    this System.Collections.Generic.IEnumerable<TSource> source,
    Func<TSource,TKey> keySelector);
Enumerable.OrderBy メソッド (System.Linq)
シーケンスの要素を昇順に並べ替えます。

IQueryableのOrderByは引数にExpressionを指定しますが、IEnumerableではFuncになっています。

普通に使う時はどちらの引数もラムダ式になるので、違いを意識することはあまりないですね。

しかし、今回はIQyeryable版で直接Expressionを使っているので、IEnumerable版にはそのまま移行できません。
ExpressionからFuncへ変換する必要があるわけです。

実はExpressionにはCompileというメソッドがあって、これでFuncへ変換できます。
IEnumerable版のソースでいえば下記の部分を編集すれば動くようになります。

// OrderBy メソッドを実行
return (IOrderedEnumerable<T>)genericMethod
    .Invoke(genericMethod, new object[] { query, selector.Compile() });  // selector → selector.Compile()

これでIEnumerableでも動作するようになりました。

・・・???

あれ?

LINQ to Entities じゃなくて LINQ to Objects なはずなのに、なんでこんなことをしないといけないんだ?

考えてみれば、最初からExpressionなんて必要ないはずじゃない?

IEnumerable版拡張メソッドの最終形

ということで、最終的にこういう実装になりました。

using System.Linq;

namespace EfSampleApp.Models;

public static class EnumerableExtensions
{
    public static IOrderedEnumerable<T> OrderBy<T>(this IEnumerable<T> query, string propertyName)
    {
        return query.OrderBy(x => typeof(T).GetProperty(propertyName).GetValue(x));
    }

    public static IOrderedEnumerable<T> OrderByDescending<T>(this IEnumerable<T> query, string propertyName)
    {
        return query.OrderByDescending(x => typeof(T).GetProperty(propertyName).GetValue(x));
    }

    public static IOrderedEnumerable<T> ThenBy<T>(this IOrderedEnumerable<T> query, string propertyName)
    {
        return query.ThenBy(x => typeof(T).GetProperty(propertyName).GetValue(x));
    }

    public static IOrderedEnumerable<T> ThenByDescending<T>(this IOrderedEnumerable<T> query, string propertyName)
    {
        return query.ThenByDescending(x => typeof(T).GetProperty(propertyName).GetValue(x));
    }
}

なんだか一周回って前回の最初の実装に戻ったような感じです。

でもこれで動くならあえてExpressionにする必要はないですね。
シンプルで分かりやすいというのは正義ですww

まとめ

今回は前回のおまけって感じの軽い記事になりました。

前回作ったIQueryable.OrderByのカスタマイズ実装を扱いやすくするだけって感じの内容でしたね。

ただ、今回作ったQueryableExtensionsクラスとEnumerableExtensionsクラスはコピペするだけで、他のプロジェクトでも使えます。

EntityFrameworkを使うプロジェクトで同様の要件があったら、参考にしてみてください。

ぜひ前回の記事も覗いてみてくださいね。

コメント

タイトルとURLをコピーしました