Azure Mobile Services の node.js バックエンドによる MongoDB コレクションのクエリ


このポストは、7 月 23 日に投稿した Querying MongoDB collections via the Azure Mobile Service node.js backend の翻訳です。

前回の記事 (英語) では、Azure Mobile Services の Table を使用して Mongo データベースを既定の Azure SQL Database のストレージ レイヤーの代わりに使用する方法についてご紹介しました。このときは、作成、更新、削除、読み込みといった操作の実装方法を説明しましたが、クエリ機能については、Table の読み込み操作がコレクションのアイテムを 1 つだけ返すのかすべて返すのかを説明しただけであったため、説明が十分ではありませんでした。今回の記事では、これまで説明したことがなかった node.js ランタイムの機能について説明したいと思います。この機能では、ユーザーから渡されるクエリ パラメーターを取得することで、ページング、並べ替え、選択、数種類のフィルタリングがサポートされ、MongoDB コレクションを Mobile Services の node.js から完全 (またはほぼ完全) に使用できるようになります。

クエリ コンポーネント

Mobile Services の node.js で MongoDB コレクションのクエリを実行する方法について説明した記事はいくつかあります (例 1例 2、いずれも英語)。クエリの実行方法としては、カスタマイズされたクエリ文字列のパラメーターで行う方法と、コード内にハードコードする方法があります。その中でも特に、クライアントが送信する OData のクエリ パラメーター (英語) に対応するランタイムを使用すると、クライアント アプリケーションのコードをより自然な形で作成できるのでおすすめです。具体的には、クライアントのコードは以下のように作成できます。

var table = client.GetTable<Order>();

var last10Orders = await table

    .OrderByDescending(o => o.OrderDate)

    .Take(10)

    .ToListAsync();

また、このランタイムには、Mongo コレクションのクエリを実行するときに上記のオプションを適用します。上記のコードは、/tables/order?$top=10&$orderby=OrderDate desc というクエリ文字列の HTTP 要求に送信します。OData のクエリ パラメーター (‘$’ で始まるもの) には、読み込みスクリプトの request オブジェクトparameters プロパティで公開されないという問題があります。ただし、getComponents を公開する query オブジェクトの機能 (今回初めて説明する機能) を使用すると、読み込み操作で取得することができます。この方法では、ページング (skip/top)、順序付け、選択、フィルタリングなどのクライアントから送信される OData のパラメーターをすべて取得できます。ここで、クエリ コンポーネントを出力するように読み込みスクリプトを変更します。

function read(query, user, request) {

    console.log(query.getComponents());

    request.execute();

}

これで、上記の要求がサーバーに送信されるようになります。Mobile Services のログを見ると、クエリ コンポーネントから取得可能な情報をすべて確認できます。

{ filters: null,

  selections: [],

  projection: null,

  ordering: { OrderDate: false },

  skip: null,

  take: 10,

  table: 'complexOrders',

  context: undefined,

  includeTotalCount: false,

  version: 2 }

このコンポーネントには、スクリプトで OData のクエリ パラメーターを読み込むために必要な情報がすべて含まれています。以下では、その使用方法について説明します。

ページング (take/skip)

前回の記事に戻って、Mongo コレクションから複数のアイテムを取得する場合について見てみましょう。

function returnMultipleObjects(collection, query, mongoHelper, request) {

 

// 目的: クエリ パラメーターを確認する。ここではすべてのアイテムを返す。

    collection.find().toArray(function(err, items) {

        if (err) {

            console.log('error querying collection: ', err);

            request.respond(200, { error: err });

        } else {

            items.forEach(function(item) {

                mongoHelper.mongoIdToMobileServiceId(item);

            });

            request.respond(200, items);

        }

    });

}

ここでは collection.find メソッド (英語) を使用していて、クエリ オプションのパラメーターを追加できます。その中で、limitskip はそれぞれ OData パラメーターの $top$skip に相当します。これにより、find メソッドに上記のオプションを追加できます。

function returnMultipleObjects(collection, query, mongoHelper, request) {

    var findOptions = {};

 

    var queryComponents = query.getComponents();

 

    applyTopAndSkip(findOptions, queryComponents);

    applyOrdering(findOptions, queryComponents);

    applySelect(findOptions, queryComponents);

    var findQuery = getFilterQuery(queryComponents, request);

    if (findQuery === null) {

     // 送信済みの応答

        return;

    }

 

    collection.find({}, findOptions).toArray(function(err, items) {

        if (err) {

            console.log('error querying collection: ', err);

            request.respond(200, { error: err });

        } else {

            items.forEach(function(item) {

                mongoHelper.mongoIdToMobileServiceId(item);

            });

            request.respond(200, items);

        }

    });

 

    function applyTopAndSkip(findOptions, queryComponents) {

        if (queryComponents.take) {

            findOptions.limit = queryComponents.take;

        }

 

        if (queryComponents.skip) {

            findOptions.skip = queryComponents.skip;

        }

    }

 

    function applySelect(findOptions, queryComponents) { }

    function applyOrdering(findOptions, queryComponents) { }

    function applyFilter(queryComponents, request) { }

    function getFilterQuery(queryComponents, request) { return {}; }

}

これで、/tables/order?$skip=3&$top=5 という要求を送信すると、最初の 3 つを無視して 5 つのドキュメントのみを取得するようになります (ただし、コレクションの既定の順序に従います。この順序は後で取得します)。さらに、この要求に $top クエリ パラメーターが含まれていない場合、queryComponents.take の値は 0 にはなりません (この場合、node.js ランタイムで返される既定の最大アイテム数である 50 になります)。このため、find オプションで制限を定義する場合にはこの値を確認することを推奨します。

順序付け

ページング (skip/take) は強力な機能で、ページングの実行時にクライアントの名前順や日付順などの順序を定義することができます。クエリ コンポーネントの出力を見ると、この順序は、フィールドを表すキーと順序の要求が昇順か降順かを示すブール値を含むオブジェクトとして定義されていることがわかります。このため、MongoDB ノード パッケージに準拠した書式 (フィールド名を含む配列または順序) に従う必要があります。

function applyOrdering(findOptions, queryComponents) {

    var orderBy = [];

    var ordering = queryComponents.ordering;

    for (var orderField in ordering) {

        if (ordering.hasOwnProperty(orderField)) {

            var ascending = queryComponents.ordering[orderField] ? 'ascending' : 'descending';

            orderBy.push([ orderField, ascending ]);

        }

    }

 

    if (orderBy.length) {

        findOptions.sort = orderBy;

    }

}

これで、この記事の序盤で説明した /tables/order?$top=10&orderby=orderDate desc というコード スニペットを実行できます。

選択

MongoDB のドキュメント (および通常データベースに格納されているエンティティ) には、必須以外の情報 (プロパティまたは列) が含まれていることがあり、これらをすべて取得していると帯域幅と処理に不必要なコストがかかってしまいます。OData および MongoDB では、次のように、取得するフィールドを選択して、より多くのクエリ コンポーネントを一度に MongoDB ノードのドライバーに準拠した書式にマッピングすることができます。

function applySelect(findOptions, queryComponents) {

    var selects = queryComponents.selections;

    if (selects && selects.length) {

        if (selects.length === 1 && selects[0] === '*') {

            // $select がない場合と同様に、何の処理も行わない

        } else {

            findOptions.fields = {};

            selects.forEach(function(field) {

                findOptions.fields[field] = 1;

            });

        }

    }

}

クライアント名を新しい順に 10 個だけ必要な場合のコードは、/tables/order?$top=10&$orderby=orderDate desc&$select=client となります。

フィルタリング

コレクションのすべての要素を要求するのではなく (ページングされている場合でも)、クライアントは特定の条件に一致したドキュメントのみを要求する場合もあります。たとえば、”J” から始まるクライアントの順序のみが必要な場合は以下のとおりです。

var table = Client.GetTable<Order>();

var items = await table

    .Where(o => o.Client.StartsWith("J"))

    .OrderByDescending(o => o.OrderDate)

    .Take(10)

    .ToListAsync();

上のフィルターでは、OData のクエリ パラメーターである $filter が /tables/order?$top=10&$orderby=orderDate desc&$filter=startswith(client,'J') によって送信されます。クエリ コンポーネントのログを記録している場合は、下の例のように、filters オブジェクトの ”queryString” の値でフィルターの式を確認できます。filters には他のプロパティ (args、type) が含まれていますが、この例では使用されていません。OData とこのクエリ文字列を変換するコードはサーバーのランタイムと JavaScript クライアント SDK で共有されますが、これらのプロパティはクライアントでのみ使用されるためです。

{ filters:

   { queryString: 'startswith(client,\'J\')',

     args: [],

     type: 'LiteralExpression' },

  selections: [],

  projection: null,

  ordering: { orderDate: false },

  skip: 0,

  take: 10,

  table: 'complexOrders',

  context: undefined,

  includeTotalCount: false,

  version: 4 }

さらに、このフィルターを使用して結果を node.js の MongoDB ライブラリの collection.find メソッド (英語) に渡すこともできます。しかし、これは簡単な作業ではありません。フィルターは文字列として指定されているため、手動でこれを解析してノードのドライバーに準拠した適切な書式に変換する必要があります。次に示すのは、いくつかのコンストラクト (単純なバイナリ演算子と ”startswith” 関数) をサポートする場合の例です。ただし、完全に実装する場合は、クエリ文字列を解析して式ツリーに変換し、同等の MongoDB のクエリ オブジェクトを作成するように検討する必要があります。これはほぼクエリ コンポーネントの機能の概要にあたるため、この記事では取り扱いません。

function getFilterQuery(queryComponents, request) {

// 単純な場合: フィルターがすべてを除外し、データベースと通信を行う必要がない

    if (queryComponents.filters && queryComponents.filters.queryString === 'false') {

        request.respond(200, []);

        return null;

    }

 

    var findQuery = convertFilter(queryComponents.filters);

    if (findQuery === null) {

        request.respond(500, { error: 'Unsupported filter: ' + queryComponents.filters.queryString });

        return null;

    }

 

    return findQuery;

}

 

function convertFilter(filters) {

    var findQuery = {};

 

    var startsWith = [ /^startswith\(([^,]+),\'([^\']+)\'\)/, function(p) {

        var field = p[1];

        var value = p[2];

        var result = {};

        result[field] = new RegExp('^' + value);

        return result;

    } ];

 

    var binaryOperator = [ /^\(([^\s]+)\s+([^\s]{2})\s(.+)$/, function(p) {

        var field = p[1];

        var operator = p[2];

        var value = p[3].slice(0, -1); // 最後の ")" を削除

        if (/datetime\'\d{4}-\d{2}-\d{2}T\d{2}\:\d{2}\:\d{2}\.\d{3}Z\'/.test(value)) {

        // 日付リテラル

            value = new Date(Date.parse(value.slice(9, -1)));

        } else if (/^\'.+\'$/.test(value)) {

        // 文字列リテラル

            value = value.slice(1, -1);

        } else {

        // 数値

            value = parseFloat(value);

        }

 

        var result = {};

        if (operator === 'eq') {

            result[field] = value;

        } else {

            result[field] = {};

            result[field]['$' + operator] = value;

        }

        return result;

    } ];

 

    var supportedFilters = [startsWith, binaryOperator];

 

    if (filters) {

    // 簡単な場合

        if (filters.queryString === 'true') {

            return {};

        }

 

        var foundMatch = false;

        for (var i = 0; i < supportedFilters.length; i++) {

            var match = filters.queryString.match(supportedFilters[i][0]);

            if (match) {

                findQuery = supportedFilters[i][1](match);

                foundMatch = true;

                break;

            }

        }

 

        if (!foundMatch) {

            return null;

        }

    }

 

    return findQuery;

}

これで、サポートされているどの Mobile Services のクライアント SDK を使用しても、複雑な MongoDB のクエリを実行できるようになりました。

まとめ

この記事では、query オブジェクトを使用して読み込みスクリプトが OData のクエリ パラメーターにアクセスできるようにする機能 (この記事で初めて説明)、およびこの機能を使用して MongoDB コレクションに複雑なクエリを実行する方法についてご紹介しました。この機能は、フィルターの変換のサポートなどに改良の余地がありますが、この機能を使用することで、この記事 (および前回の記事) で取り扱った例、つまり、すべてのクライアントがサポートしている同一の Table の抽象化を他のバックエンド ストレージに拡張する、というシナリオが可能になります。

今回の記事で使用したすべてのコードは、こちらのサンプル リポジトリ (英語) からダウンロードできます。ご意見やご感想がありましたら、お気軽にこの記事のコメント欄か MSDN のフォーラムまでお寄せください。

Comments (0)

Skip to main content