【C# EntityFramework】ラムダ式ではなくプロパティ名で指定するOrderByの作り方

技術

厄介な相談

.Net6のEntityFrameworkを使ったWebアプリ開発をしている現場である時、こんな相談を受けました。

LINQ to EntiiesのOrderByでソート条件を文字列にしたいんだけど、どうしたらいい?

最初、何を言っているのか分からなかったのだけど話を聞いていると、どうもちょっと特殊なことをしようとしているらしいことが分かってきた。

つまりこういう事だった。

  1. 画面にはデータを表示するテーブルがあってページネーションと並べ替えの仕組みがある。
  2. ページ送り操作や並べ替え操作をするとリクエストにはデータの取得範囲を表すオフセットと取得件数の条件とともに並べ替えの条件が文字列として指定されてくる。
  3. この並べ替え条件をLINQ to EntitiesのOrderByに指定してSQLを組み上げたい。

説明するより具体的な実装を見たほうが分かりやすいと思う。

相談してくれた方が最初に見せてくれたコードは大体こういう感じでした。
(分かりやすいように、多少シンプルなコードにアレンジしてます)

public IEnumerable<User> Select(DbContext context, int offset, int count, string orderName, string orderDirection)
{
    var entityType = typeof(User);
    var query = context.Users
        .Where(user => !user.DeleteFlag);
    if (orderDirection == "asc")
        query = query.OrderBy(user => entityType.GetProperty(orderName).GetValue(user));
    else
        query = query.OrderByDescending(user => entityType.GetProperty(orderName).GetValue(user));
    return query.Skip(offset).Take(count);
}

例えば、オフセットに20、件数に10、並べ替え条件に”Age”、”asc”と指定すると

SELECT
  id,
  name,
  age,
  delete_flag
FROM Users
WHERE delete_flag = 0
ORDER BY age ASC
OFFSET 20 ROWS 
FETCH NEXT 10 ROWS ONLY

こんな感じのSQLが実行されることをイメージしていたようです。
ちなみにDBはSQL Serverです。

ところが実際に実行してみると例外が発生します。

Unhandled exception. System.InvalidOperationException: The LINQ expression ‘DbSet()
.Where(u => !(u.DeleteFlag))
.OrderBy(u => __GetProperty_0.GetValue(u))’ could not be translated. Either rewrite the query in a form that can be translated, or switch to client evaluation explicitly by inserting a call to ‘AsEnumerable’, ‘AsAsyncEnumerable’, ‘ToList’, or ‘ToListAsync’. See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.

つまり、「GetPropertyを使ったらEntityFrameworkはSQLを組み立てられないよ」と言っているわけです。

で、この”エラーを解消するために”ということで、次に見せてくれたのはこのコードでした。

public IEnumerable<User> Select(DbContext context, int offset, int count, string orderName, string orderDirection)
{
    var entityType = typeof(User);
    var query = context.Users
        .Where(user => !user.DeleteFlag).AsEnumerable();
    if (orderDirection == "asc")
        query = query.OrderBy(user => entityType.GetProperty(orderName).GetValue(user));
    else
        query = query.OrderByDescending(user => entityType.GetProperty(orderName).GetValue(user));
    return query.Skip(offset).Take(count);
}

Select メソッドの上から3行目にAsEnumerableが使われているのがわかると思います。

確かにこれなら例外にはなりません。

ところが実行してみるとわかると思いますが、これはorder by句を含むSQLにはなりません。
実際に実行されるSQLはこんな感じです。

SELECT
  id,
  name,
  age,
  delete_flag
FROM Users
WHERE delete_flag = 0

WHERE句まででSQLが途切れてしまっています。
AsEnumerableを使うとその時点でSQLが発行されてしまい、それ以降は LINQ to Entities ではなく LINQ to Object として実行されるのです。
(※厳密にはちょっと違いますが、おおむねそういう理解で大丈夫そう)

つまり、DBからデータを取得した後でメモリ上で並べ替え(OrderBy)が行われるわけです。

実際のところ、結果としてはほとんど違わないので、これでも問題ないと言えば問題ないと思いますが、相談してくれた人はあくまでもDB上で並べ替えたいので order by 句を含むSQLで実行したいんだそうです。

そんなわけで調べてみたら、案外すんなりできちゃいました。

式ツリーって何?

できたと言っても、自分で1から考えたわけではなく、すでに誰かがやったコードを見つけただけなんですけど。

それがこちらのstack overflowです。

Generate EF orderby expression by string
I want to generate expression by string parameter,some code like: private Expression<Func<Task, T>> Generate(string orde...

これを参考にしてコードを直してみました。

public IEnumerable<User> Select(DbContext 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(System.Linq.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);
}

OrderByの引数に本来指定される「user => user.Age」というラムダ式は式ツリー(式木)と言われるものとしてコンパイルされます。
式ツリーになることで実行時にSQLに変換されるのです。

で、今回のようにラムダ式でうまく式ツリーを作れないのなら、最初から式ツリーを作って渡してしまえばいいよね、っていうことみたいです。

あとはOrderByメソッドをリフレクションで取得して、式ツリーを渡して実行すればいいと。

これで期待通りにorder byまで含んだSQLが発行されるようになりました。

環境を作って試してみる

せっかくなのでDockerに環境を作って、上のコードが正しく動くか確認してみます。

今回はdocker-composeをつかってみます。

version: '3.9'
services:
  db:
    image: mcr.microsoft.com/mssql/server:2019-latest
    container_name: ef_sample_db
    restart: always
    ports:
      - 1433:1433
    environment:
      ACCEPT_EULA: Y
      MSSQL_SA_PASSWORD: saPassword1234
      MSSQL_PID: Express
      MSSQL_LCID: 1041
      MSSQL_COLLATION: Japanese_CI_AS
    volumes:
      - .\db\data:/var/opt/mssql/data
      - .\db\log:/var/opt/mssql/log
      - .\db\secrets:/var/opt/mssql/secrets
  ef:
    image: mcr.microsoft.com/dotnet/sdk:6.0
    container_name: ef_sample_dev
    restart: always
    volumes:
      - .\app:/usr/local/src/work
    tty: true
    depends_on:
      - db

特に説明することもないですが、ef_sample_dbef_sample_devという名前の2つのdockerコンテナを作っています。

ef_sample_dbはSQLServer 2019がセットアップされているコンテナでポート1433でアクセスできます。
saユーザーは環境変数MSSQL_SA_PASSWORDに指定されている値になります。
ただし、パスワードは一定の複雑さ要件を満たさないとダメみたいです。

このdocker-compose.ymlを任意のフォルダに保存して、その隣に「app」と「db」フォルダ、さらに「db」フォルダの下に「data」「log」「secrets」フォルダを作成しておきます。

あとは docker-compose.yml の保存されているフォルダでコマンドプロンプトを開いて、下記のようにdocker-compseコマンドをたたくだけです。

docker-compose up -d

念のためコンテナを確認しておきます。

docker ps -a

結果はこんな感じになっているはずです。

VS CodeのRemote Development拡張を使ってef_sample_devコンテナにアクセスします。

ef_sample_devコンテナからef_sample_dbのSQLServerへはホスト名「db」でアクセスできます。
docker-compose.ymlの3行目で「db:」と定義して、26~27行目で「depends_on: db」としているためです。

では、本当に接続できるか、VS CodeのSQL Server (mssql) 拡張を使って試してみます。

SQL Server (mssql) - Visual Studio Marketplace
Extension for Visual Studio Code - Develop Microsoft SQL Server, Azure SQL Database and SQL Data Warehouse everywhere

ef_sample_devコンテナにアクセスしているVSCode上でSQL Server (mssql) 拡張をインストールします。

インストールできたら下記の画像のようにSQL Server View(①)を開いて、CONNECTIONSを追加(②)します。

コマンドパレットで接続情報の入力が促されるので、下記のように入力します。

No.質問入力
1Server name or ADO.NET connection stringdb,1433
2Database name(空白のまま’Enter’)
3Authentication TypeSQL Login
4User namesa
5PasswordsaPassword1234
6Save Password?Yes
7Profile Nameef_sample_db

VS Codeの右下に下記のような警告が表示されたら、Enable Trust Server Certificateをクリックすればef_sample_dbコンテナのDBにアクセスできるはずです。

DB作成

DBに接続できたら SQL Server View に「ef_sample_db」が表示されているはずです。
右クリックして「New Query」を選択します。

SQLエディタが開いたら、下記のSQLを張り付けて実行してください。

-- CREATE DATABASE
CREATE DATABASE sample_db collate Japanese_CI_AS;
GO

-- CREATE LOGIN  
CREATE LOGIN usr WITH PASSWORD = 'usrPassword1234';  
GO  

USE sample_db;

-- CREATE USER  
CREATE USER usr FOR LOGIN usr;  
GO

-- Authentication
EXEC sp_addrolemember 'db_datareader', 'usr';
GO
EXEC sp_addrolemember 'db_datawriter', 'usr';
GO

sample_dbという名前でDBを作って、usrというユーザーでアクセスできるようにしています。
usrのパスワードは「usrPassword1234」です。

続けてテーブルを作って、サンプルデータを入れておきます。

-- CREATE TABLE
CREATE TABLE sample_db.dbo.users
(
    id INT NOT NULL IDENTITY PRIMARY KEY,
    name NVARCHAR(50) NOT NULL,
    age INT,
    delete_flag BIT NOT NULL DEFAULT 0
);
GO

-- Sample data
INSERT INTO sample_db.dbo.users
    (name, age)
VALUES
    ('伊藤博文', 20),
    ('黒田清隆', 32),
    ('山縣有朋', 18),
    ('松方正義', 46),
    ('大隈重信', 9),
    ('桂太郎', 12),
    ('西園寺公望', 51);

これでDBの準備はOKです。

.NET プロジェクトを準備

DBが準備できたら次はそのDBを使うC#側を準備します。

「/usr/local/src/work」ディレクトリに移動して、下記のコマンドで.NETプロジェクトを作成します。

dotnet new console --name EfSampleApp

今回はEntity Frameworkを触ってみるだけなのでコンソールアプリにしました。

プロジェクトが生成できたら続けてEntityFrameworkをインストールします。

cd /usr/local/src/work/EfSampleApp
dotnet add package Microsoft.EntityFrameworkCore.SqlServer, Microsoft.EntityFrameworkCore.Design

さらに、dotnet efコマンドが使えるようにEntity Frameworkのツールをインストールします。

dotnet tool install --global dotnet-ef
echo 'export PATH=$PATH:'/root/.dotnet/tools >> ~/.bashrc
source ~/.bashrc

~/.bashrcファイルにexport PATH=$PATH:/root/.dotnet/toolsを追記しておくことで terminal が新しくなってもdotnet efコマンドが使えるようにしています。

準備ができたら早速 EfSampleApp に Entity Framework を導入してみましょう。

cd /usr/local/src/work/EfSampleApp
dotnet ef dbcontext scaffold "Data Source=db,1433;Initial Catalog=sample_db;User ID=usr;Password=usrPassword1234;TrustServerCertificate=true;" Microsoft.EntityFrameworkCore.SqlServer -t users -f -o Models

このコマンドでEfSampleApp/Modelsの下にSampleDbContextUserというクラスが作られたはずです。

この SampleDbContext には Users というプロパティがあって sample_db.dbo.users テーブルにアクセスできるわけです。
そんなわけで早速アクセスするサンプルコードを作ってみます。

using EfSampleApp.Models;

var dbContext = new SampleDbContext();
var query = dbContext.Users.OrderBy(u => u.Age).Skip(3).Take(3);
foreach (var user in query)
{
    Console.WriteLine($"id[{user.Id}],name[{user.Name}],age[{user.Age}]");
}
Console.WriteLine("===================");

SQLを組み立てているのは下記の部分ですね。

var query = dbContext.Users.OrderBy(u => u.Age).Skip(3).Take(3);

実行してみるとこんな感じに出力されました。

cd /usr/local/src/work/EfSampleApp 
dotnet run

id[1],name[伊藤博文],age[20]
id[2],name[黒田清隆],age[32]
id[4],name[松方正義],age[46]
===================

うまく動いているようです。

ただ、このままではどんなSQLが実行されたのか分からないので、SQLがコンソールに出力されるようにしておきます。

SampleDbContext.cs を下記のように編集します。

using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
// ===== 追加(ここから) =====
using Microsoft.Extensions.Logging;
using Microsoft.EntityFrameworkCore.Diagnostics;
// ===== 追加(ここまで) =====

namespace EfSampleApp.Models;

public partial class SampleDbContext : DbContext
{
    public SampleDbContext()
    {
    }

    public SampleDbContext(DbContextOptions<SampleDbContext> options)
        : base(options)
    {
    }

    public virtual DbSet<User> Users { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
#warning To protect potentially sensitive information in your connection string, you should move it out of source code. You can avoid scaffolding the connection string by using the Name= syntax to read it from configuration - see https://go.microsoft.com/fwlink/?linkid=2131148. For more guidance on storing connection strings, see http://go.microsoft.com/fwlink/?LinkId=723263.
        => optionsBuilder.UseSqlServer("Data Source=db,1433;Initial Catalog=sample_db;User ID=usr;Password=usrPassword1234;TrustServerCertificate=true;")
            // ===== 追加(ここから) =====
            .LogTo(
                    message => Console.WriteLine(message),
                    new[] { DbLoggerCategory.Database.Name },
                    LogLevel.Information, 
                    DbContextLoggerOptions.LocalTime);
            // ===== 追加(ここまで) =====

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<User>(entity =>
        {
            entity.HasKey(e => e.Id).HasName("PK__users__3213E83F9AB61C6F");

            entity.ToTable("users");

            entity.Property(e => e.Id).HasColumnName("id");
            entity.Property(e => e.Age).HasColumnName("age");
            entity.Property(e => e.DeleteFlag).HasColumnName("delete_flag");
            entity.Property(e => e.Name)
                .HasMaxLength(50)
                .HasColumnName("name");
        });

        OnModelCreatingPartial(modelBuilder);
    }

    partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}

OnConfiguringメソッドでoptionsBuilder.LogToを実行してEntityFramework上のログをConsole.WriteLineに出力するようにしてます。

再び実行してみると、こんな感じでコンソール上でSQLが確認できました。

9/24/2023 01:10:05.226
Executed DbCommand (39ms) [Parameters=[@__p_0=’?’ (DbType = Int32)], CommandType=’Text’, CommandTimeout=’30’]
SELECT [u].[id], [u].[age], [u].[delete_flag], [u].[name]
FROM [users] AS [u]
ORDER BY [u].[age]
OFFSET @__p_0 ROWS FETCH NEXT @__p_0 ROWS ONLY
id[1],name[伊藤博文],age[20]
id[2],name[黒田清隆],age[32]
id[4],name[松方正義],age[46]
===================

Selectメソッドを動作確認

再び Program.cs を下記のように編集して、先ほど作ったSelectメソッドが動作するか確認してみます。

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(System.Linq.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("===================");

Selectメソッドは そのままでは使えなかったので、2点だけ変更しました。

  1. Program.cs にメソッド定義する場合、アクセス指定子が使えず、staticメソッドにする必要があるので、publicを削除、staticを追加
  2. DbContextはSampleDbContextに変更

これで実行してみると、下記のように出力されました。

9/24/2023 01:12:09.071
Executed DbCommand (42ms) [Parameters=[@__p_0=’?’ (DbType = Int32)], CommandType=’Text’, CommandTimeout=’30’]
SELECT [u].[id], [u].[age], [u].[delete_flag], [u].[name]
FROM [users] AS [u]
WHERE [u].[delete_flag] = CAST(0 AS bit)
ORDER BY [u].[age]
OFFSET @__p_0 ROWS FETCH NEXT @__p_0 ROWS ONLY
id[1],name[伊藤博文],age[20]
id[2],name[黒田清隆],age[32]
id[4],name[松方正義],age[46]
===================

期待通り、ORDER BY句がSQLに含まれていることが確認できます。

まとめ

今回は Entity Framework の Linq to Entities で OrderBy メソッドをラムダ式ではなく、stringのプロパティ名で指定する方法について調べてみました。

正直な話、今回のように「SQLの生成に関与したい」という要件が出てきた時点で LINQ to Entities を使うのはあきらめて、素直にSQLを書いてしまった方がいいんじゃないかと個人的には思います。

とはいえ、普段あまりいじることこのない「式ツリー(式木)」をあれこれと触れたことは収穫でした。
式ツリーについて学ぶいい機会になったと思います。

式ツリーの理解を深めれば、WhereメソッドやSelectメソッドでもカスタマイズできそうです。
式ツリー研究してみても面白いかもしれません。

といったところで、今回はここまでにしたいと思います。

追記

実は後日談というか、今回の話にはもう少し続きがあります。
この続きは次回の記事になりました。
今回の実装を使いやすく汎用化してみた!って内容です。

よかったらそちらもぜひ読んでみてください。

コメント

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