2015年7月1日水曜日

web2py GAEのカーソルとbelongs(IN演算子)

GAE(Google App Engine)の Datastore環境でカーソルを使用した場合、belongs メソッドでエラーが出て動きません。これは記述を変更することで、動作することが解りました。この件について、メモ書き記事にします。

belongs は、SQLのIN演算子に変換されます。belongs の詳しい説明は、次のリンクを参照ください。

参考: web2py補足ドキュメント - Expression.belongs(value)

テスト用コード

次のようなコードを考えます。

モデル

db.define_table('person',
    Field('name'), format='%(name)s')

db.define_table('dog',
    Field('owner_id', db.person), 
    Field('birthday', 'date', requires=IS_DATE(format='%Y-%m-%d')),
    Field('name'))

コントローラ

def list():
    from datetime import date, timedelta
    today = date.today()
    date_last_year = today - timedelta(days=365)
    owner = [row.id for row in db(db.person).select()]

    dbset = db(db.dog.birthday >= date_last_year)\
        (db.dog.birthday <= today)\
        (db.dog.owner_id.belongs(owner))
    rows = dbset.select()
    return dict(rows=rows)

このコードは、GAE環境でも特に問題なく動作します。ちなみにコントローラのコードの9行目で belongs を使用しています。

エラーが発生するコード

それではGAE環境で、エラーの発生するコードを見てみましょう。

def list():
    from datetime import date, timedelta
    today = date.today()
    date_last_year = today - timedelta(days=365)
    owner = [row.id for row in db(db.person).select()]

    dbset = db(db.dog.birthday >= date_last_year)\
        (db.dog.birthday <= today)\
        (db.dog.owner_id.belongs(owner))
    rows = dbset.select(limitby=(0,10))
    return dict(rows=rows)

10行目の select メッソッドに、limitby を設定しました。limitby は、セレクトするレコード数を指定するオプションです。

参考: web2py補足ドキュメント - Set.select([*fields, **attributes])

GAE以外の環境では問題なく動きます。GAE環境では、次のようなエラーメッセージがログに記録されます。

BadArgumentError: _MultiQuery with cursors requires __key__ order
解決策

このエラーが発生するのは、NDB API を使った場合のようです。NDB は Python の次世代 Datastore 用モジュールで、数年前に登場しました。web2py でもGAE環境の場合は、コードを NDB API 用に変換するようになっています。

参考:
BadArgumentError: _MultiQuery with cursors requires __key__ order in ndb
NDB Datastore API - Query Cursors

NDB ではクエリーとして、IN / OR / != の各演算子とカーソルを同時に使用した場合、上記のエラーが発生する仕様になっています。

「ん?、カーソルは使用していないぞ」 と思われるかもしれませんが、web2py では limitby オプションを利用した場合、カーソルを使用するようになっています。

問題解決のためには、クエリーで使用するフィールドでソートしてあげればよいです。具体的には、次のようにコード変更します。

def list():
    from datetime import date, timedelta
    today = date.today()
    date_last_year = today - timedelta(days=365)
    owner = [row.id for row in db(db.person).select()]

    dbset = db(db.dog.birthday >= date_last_year)\
        (db.dog.birthday <= today)\
        (db.dog.owner_id.belongs(owner))
    rows = dbset.select(limitby=(0,10),
                        orderby=db.dog.birthday|db.dog.owner_id|db.dog.id)
    return dict(rows=rows)

11行目の select メソッドで、 orderby オプションを指定します。

参考: web2py補足ドキュメント - Set.select([*fields, **attributes])

ここで気をつける事は、ソートフィールドにクエリーで使用するフィールドを含めると共に、主キーのフィールド(idフィールド)を最後に足します。idフィールドがソートに含まれないと、プログラムは動作しません。

ちょっとレアなケースの仕様のようですが、解り難かったので記事にしました。