ブログ

LINQ to Elasticsearch ES|QL:C#を記述してElasticsearchをクエリ

Elasticsearch .NETクライアントに新しく追加されたLINQ to Elasticsearch ES|QLプロバイダをご紹介します。C#コードを自動的にES|QLクエリに変換できます。

v9.3.4およびv8.19.18以降のElasticsearch .NETクライアントには、実行時にC# LINQ式をElasticsearchクエリ言語(ES|QL)クエリに変換するLanguage Integrated Query(LINQ)プロバイダーが含まれています。ES|QL文字列を手作業で記述する代わりに、 WhereSelectOrderByGroupByなどの標準演算子を使用してクエリを構成します。このプロバイダーは、結果セットのサイズに関係なくメモリ使用量を一定に保つ行ごとのストリーミングを含め、変換、パラメータ化、結果の逆シリアル化を処理します。

最初のクエリ

まず、Elasticsearchインデックスにマップする普通のCLRオブジェクト(POCO)を定義します。プロパティ名は、標準的なSystem.Text.Json属性([JsonPropertyName]など)または設定されたJsonNamingPolicyを通じてES|QL列名に解決されます。クライアントの他の部分に適用されるソースシリアル化ルールは、ここでも同様に適用されます。

using System.Text.Json.Serialization;

public class Product
{
    [JsonPropertyName("product_id")]
    public string Id { get; set; }

    public string Name { get; set; }

    public string Brand { get; set; }

    [JsonPropertyName("price_usd")]
    public double Price { get; set; }

    [JsonPropertyName("in_stock")]
    public bool InStock { get; set; }
}

型を指定すると、クエリは次のようになります。

var minPrice = 100.0;
var brand = "TechCorp";

await foreach (var product in client.Esql.QueryAsync<Product>(q => q
    .From("products")
    .Where(p => p.InStock && p.Price >= minPrice && p.Brand == brand)
    .OrderByDescending(p => p.Price)
    .Take(10)))
{
    Console.WriteLine($"{product.Name}: ${product.Price}");
}

プロバイダーはこれを次のES|QLに変換します。

FROM products
| WHERE (in_stock == true AND price_usd >= ?minPrice AND brand == ?brand)
| SORT price_usd DESC
| LIMIT 10

いくつか注意すべき点があります。

  • プロパティ名の解決: p.Price[JsonPropertyName] 属性のためprice_usdになり、p.BrandはデフォルトのcamelCase命名規則に従って brandになります。

  • パラメーターのキャプチャ:C#変数 minPricebrandは、名前付きパラメーター(?minPrice?brand)としてキャプチャされます。これらはJSONペイロード内のクエリ文字列とは別に送信されるため、インジェクション攻撃を防ぎ、サーバー側のクエリプランのキャッシュを可能にします。

  • ストリーミング:QueryAsync<T>IAsyncEnumerable<T>を返します。Elasticsearchからデータが到着すると、行は1つずつマテリアライズされます。

また、実行せずに生成されたクエリとそのパラメーターを検査することもできます。

var query = client.Esql.CreateQuery<Product>()
    .Where(p => p.InStock && p.Price >= minPrice && p.Brand == brand)
    .OrderByDescending(p => p.Price)
    .Take(10);

Console.WriteLine(query.ToEsqlString());
// FROM products | WHERE (in_stock == true AND price_usd >= 100) | SORT price_usd DESC | LIMIT 10

Console.WriteLine(query.ToEsqlString(inlineParameters: false));
// FROM products | WHERE (in_stock == true AND price_usd >= ?minPrice AND brand == ?brand) | SORT price_usd DESC | LIMIT 10

var parameters = query.GetParameters();
// { "minPrice": 100.0, "brand": "TechCorp" }

これはどのように機能するのでしょうか?LINQ の簡単なおさらい

LINQプロバイダーを可能にするメカニズムは、IEnumerable<T>IQueryable<T>の区別にあります。

.Where(p => p.Price > 100)IEnumerable<T> 上で呼び出すと、ラムダは Func<Product, bool> にコンパイルされます。これは、ランタイムがインプロセスで実行する通常のデリゲートです。これはLINQ-to-Objectsです。

同じメソッドを IQueryable<T> で呼び出すと、C#コンパイラはラムダを Expression<Func<Product, bool>> でラップします。これは実行可能な形式ではなく、コードの構造を表すデータ構造です。式ツリーは実行時に検査、分析、および別の言語への変換を行うことができます。

// IEnumerable: the lambda is a compiled delegate
IEnumerable<Product> local = products.Where(p => p.Price > 100);

// IQueryable: the lambda is an expression tree, a data structure
IQueryable<Product> remote = queryable.Where(p => p.Price > 100);

IQueryProviderインターフェースは拡張ポイントです。どのプロバイダーでも、これらの式ツリーをターゲット言語に変換するために CreateQuery<T>Execute<T> を実装できます。Entity FrameworkはSQLを発行するためにこれを使用します。LINQからES|QLへのプロバイダーはこれをES|QLの生成に使用します。

上記のクエリの式ツリーは次のようになります。

例のクエリに対する式ツリー。

例のクエリに対する式ツリー。

ツリーは内側から外側にネストされています。TakeOrderByDescendingをラップし、これがWhereをラップし、これがFrom, をルート定数EsqlQueryable<Product> をラップします。Where述語自体がBinaryExpressionノードのサブツリーであり、&&>=、および==演算子に対してMemberExpressionリーフがプロパティアクセス用、minPriceおよびbrand変数用のクロージャキャプチャ用に存在します。これは、プロバイダーが最終的なES|QLを生成するために使用するデータ構造です。

内部構造:変換パイプライン

LINQ式からクエリ結果までの経路は、6段階のパイプラインをたどります。

データ変換パイプラインの概要。

データ変換パイプラインの概要。

1. 式ツリーのキャプチャ

.Where().OrderBy().Take()などの演算子をIQueryable<T> に連鎖させると、標準のLINQインフラストラクチャーが式ツリーを構築します。EsqlQueryable<T>IQueryable<T> を実装し、EsqlQueryProvider に委譲します。

2. 変換

クエリが実行されると(列挙、 ToList()の呼び出し、またはawait foreach)使用によって)、 EsqlExpressionVisitorは式ツリーを内側から外側へと走査します。各LINQメソッド呼び出しを専門のビジターに送信します。

ビジター

翻訳します

対象

WhereClauseVisitor

.Where(predicate)

WHERE 条件

SelectProjectionVisitor

.Select(selector)

EVAL + KEEP + RENAME

訪問者別にグループ化

.GroupBy().Select()

STATS ... BY

OrderByVisitor

.OrderBy() / .ThenBy()

SORTフィールド [ASC\|DESC]

EsqlFunctionTranslator

EsqlFunctions.*、Math.*、文字列メソッド

80+ ES|QL関数

翻訳中、式で参照されるC#変数は名前付きパラメーターとしてキャプチャされます。

3. クエリモデル

ビジターは直接文字列を生成しません。代わりに、QueryCommandオブジェクト、すなわち不変の中間表現を生成します。FromCommandWhereCommandSortCommand、およびLimitCommandの各々が、1つのES|QL処理コマンドを表しています。これらはEsqlQueryモデルに集められます。

クエリモデルとコマンドパターン。

クエリモデルとコマンドパターン。

この中間モデルは、式ツリーと出力形式の両方から切り離されています。フォーマット前に検査、傍受(IEsqlQueryInterceptor経由)、または修正が可能です。

4. フォーマット

EsqlFormatterQueryCommandを順番に訪問し、最終的なES|QL文字列を生成します。各コマンドは1行になり、ES|QLが処理コマンドを連鎖させるために使用するパイプ (|) 演算子で区切られます。特殊文字を含む識別子は自動的にバッククォートでエスケープされます。

5. 実行

フォーマットされたES|QL文字列とキャプチャされたパラメーターは、JSONペイロードとしてElasticsearchの/_queryエンドポイントに送信されます。IEsqlQueryExecutorインターフェースはトランスポートレイヤーを抽象化し、ここで階層型パッケージアーキテクチャが登場します。

6. マテリアライズ

EsqlResponseReader JSON応答をストリーム化し、結果セット全体をバッファリングせずに処理します。ColumnLayoutツリーは、1クエリにつき1回事前に計算され、フラットなES|QL列名(address.streetaddress.cityなど)をネストされたPOCOプロパティにマップします。各行はTインスタンスに組み立てられ、 IEnumerable<T> または IAsyncEnumerable<T>によって1行ずつ生成されます。

レイヤーアーキテクチャ

LINQ to ES|QL機能は、以下の3つのパッケージに分かれています。

パッケージアーキテクチャー。

パッケージアーキテクチャー。Elastic.Esql は純粋な変換エンジンです。HTTPへの依存関係は一切なく、式ビジター、クエリモデル、フォーマッター、レスポンスリーダーが含まれています。スタンドアロンで使用すると、Elasticsearch接続がなくてもES|QLクエリを構築および検査できます。これは、テスト、クエリロギング、または独自の実行レイヤーの構築に役立ちます。

// Translation-only: no Elasticsearch connection needed
var provider = new EsqlQueryProvider();
var query = new EsqlQueryable<Product>(provider)
    .From("products")
    .Where(p => p.InStock)
    .OrderByDescending(p => p.Price);

Console.WriteLine(query.ToEsqlString());
// FROM products | WHERE in_stock == true | SORT price_usd DESC

Elastic.Clients.Esql は軽量なスタンドアロンのES|QLクライアントです。Elastic.Transportを経由してElastic.Esql上にHTTP実行を追加します。もしアプリケーションが他のElasticsearch APIではなく、ES|QLのみを必要とする場合、これが最小限の依存関係オプションです。

Elastic.Clients.Elasticsearch は完全なElasticsearch .NETクライアントです。また、Elastic.Esql を基盤とし、client.Esql名前空間を通じてLINQプロバイダーを公開します。これはほとんどのアプリケーションで推奨されるエントリーポイントです。

どちらの実行層パッケージも、変換と転送をつなぐ戦略インターフェースであるIEsqlQueryExecutorの独自の実装を提供します。

これら3つのパッケージはすべて、ソース生成のJsonSerializerContextと併用する場合、ネイティブAOTと互換性があります。完全なクライアントについては、Native AOTのドキュメントをご覧ください。

基本を超えて

上記の例では、フィルタリング、ソート、ページネーションについて説明しています。このプロバイダーはより幅広い操作をサポートしています。

アグリゲーション

GroupBySelectの集約関数と組み合わせるとES|QL STATS ... BYに変換されます。

var stats = client.Esql.Query<Product, object>(q => q
    .GroupBy(p => p.Brand)
    .Select(g => new
    {
        Brand = g.Key,
        Count = g.Count(),
        AvgPrice = g.Average(p => p.Price),
        MaxPrice = g.Max(p => p.Price)
    }));

// -> FROM products | STATS COUNT(*), AVG(price_usd), MAX(price_usd) BY brand

予測

Select匿名型を持つと、 EVALKEEPRENAME コマンドが生成されます。

var query = client.Esql.CreateQuery<Product>()
    .Select(p => new { ProductName = p.Name, p.Price, p.InStock });

// -> FROM products | KEEP name, price_usd, in_stock | RENAME name AS ProductName

豊富な関数ライブラリ

80以上のES|QL関数が EsqlFunctionsクラスを通じて利用可能で、日付/時間、文字列、数学、IP、パターンマッチング、スコアリングをカバーしています。標準的なMath.*およびstring.*メソッドも変換されています。

.Where(p => p.Name.Contains("Pro"))       // -> WHERE name LIKE "*Pro*"
.Where(p => EsqlFunctions.CidrMatch(      // -> WHERE CIDR_MATCH(ip, "10.0.0.0/8")
    p.IpAddress, "10.0.0.0/8"))

ルックアップ結合

クロスインデックス検索はES|QL LOOKUP JOINに変換されます。

var enriched = client.Esql.Query<Product, object>(q => q
    .LookupJoin<Product, CategoryLookup, string, object>(
        "category-lookup-index",
        product => product.Id,
        category => category.CategoryId,
        (product, category) => new { product.Name, category!.CategoryLabel }));

未加工のES|QLエスケープハッチ

LINQプロバイダーでまだサポートされていないES|QL機能については、生のフラグメントを追加できます。

var results = client.Esql.Query<Product>(q => q
    .Where(p => p.InStock)
    .RawEsql("| EVAL discounted = price_usd * 0.9"));

サーバー側の非同期クエリ

実行時間の長いクエリについては、サーバー上でバックグラウンド処理を行うように設定します。

await using var asyncQuery = await client.Esql.SubmitAsyncQueryAsync<Product>(
    q => q.Where(p => p.InStock),
    asyncQueryOptions: new EsqlAsyncQueryOptions
    {
        WaitForCompletionTimeout = TimeSpan.FromSeconds(5),
        KeepAlive = TimeSpan.FromMinutes(10)
    });

await asyncQuery.WaitForCompletionAsync();
await foreach (var product in asyncQuery.AsAsyncEnumerable())
    Console.WriteLine(product.Name);

サーバー側の非同期クエリは、通常のタイムアウトしきい値を超える可能性のある長時間実行される分析クエリや大規模データセットの処理、あるいはロードバランサー、APIゲートウェイ、プロキシなど、厳格なHTTPタイムアウトを強制するタイムアウトに敏感な環境で特に役立ちます。非同期クエリは、結果の取得から提出を切り離すことで接続切断を回避します。

はじめに

LINQ to ES|QLは次のバージョンから利用可能です。

  • Elastic.Clients.Elasticsearch v9.3.4 (9.x ブランチ)

  • Elastic.Clients.Elasticsearch v8.19.18(8.xブランチ)

NuGetからのインストール:

dotnet add package Elastic.Clients.Elasticsearch

エントリーポイントはclient.Esqlにあります。

メソッド

戻り値

ユースケース

Query<T>(...)

IEnumerable<T>

同期実行

QueryAsync<T>(...)

IAsyncEnumerable<T>

非同期ストリーミング

CreateQuery<T>()

IEsqlQueryable<T>

高度な構成と検査

SubmitAsyncQueryAsync<T>(...)

EsqlAsyncQuery<T>

長時間実行されるサーバー側クエリ

クエリオプション、複数フィールドへのアクセス、ネストされたオブジェクト、複数値フィールドの処理など、機能の詳細についてはLINQ to ES|QLのドキュメントを参照してください。

まとめ

LINQ to ES|QLは、C# LINQの完全な表現力をElasticsearchのES|QLクエリ言語にもたらし、クエリ文字列を手作業で作成することなく、厳密に型付けされた構成可能なクエリを書くことができます。自動パラメーターキャプチャ、ストリーミングマテリアライゼーション、スタンドアロン変換から完全なElasticsearchクライアントまで拡張できる階層型パッケージアーキテクチャーにより、あらゆる規模の.NETアプリケーションに自然に適合します。最新のクライアントをインストールし、LINQ式をインデックスに向け、残りはプロバイダーに任せましょう。

関連記事

ES|QL を使用した Elasticsearch 地理空間検索

Craig Taverner

スイススタイルのハッシュテーブルを使用したより高速なES|QL統計

Chris Hegarty

Google MCP Toolbox for DatabasesにElasticsearchサポートを導入

Enrico Zimuel

Kibana を使用して地理空間データを Elasticsearch に取り込み、ES|QL で使用できるようにする

Craig Taverner

Elasticsearch の ES|QL エディターエクスペリエンスと OpenSearch の PPL イベントアナライザーの比較

Libby Lin

最先端の検索体験を構築する準備はできましたか?

十分に高度な検索は 1 人の努力だけでは実現できません。Elasticsearch は、データ サイエンティスト、ML オペレーター、エンジニアなど、あなたと同じように検索に情熱を傾ける多くの人々によって支えられています。ぜひつながり、協力して、希望する結果が得られる魔法の検索エクスペリエンスを構築しましょう。

はじめましょう