読者です 読者をやめる 読者になる 読者になる

techium

このブログは何かに追われないと頑張れない人たちが週一更新をノルマに技術情報を発信するブログです。もし何か調査して欲しい内容がありましたら、@kobashinG or @muchiki0226 までいただけますと気が向いたら調査するかもしれません。

Realm JavaのRealmQueryをループで使ったらヒドい目に遭ったという話

Android Library Realm

先日からAndroidアプリの開発でRealmを使っていますが、今回はRealmの仕様なのかバグなのかよくわからない挙動にハマってヒドい目に遭ったというお話。

歴戦のRealmerには既知の問題かもしれないので何か情報をお持ちの方は適宜突っ込んでもらえると助かります。

RealmQueryで任意のデータを取得する

Realmで保存したデータを取得する際は基本的に以下のような処理を書きます。

Realm r = Realm.getInstance();

RealmResults<Hoge> h = r.where(Hoge.class).findAll();

findAll()を実行するとRealmResults<E>が取得できます*1。型Eは自分で定義したRealmObjectを継承したデータ型です。RealmResultsAbstractList<E>を継承しているため、通常のリストと同様の操作を行うことができます。

findAllを実行する前にr.where(Hoge.class).equalTo("カラム名", "値").~と指定することで、データ取得の際に条件を指定する事ができます。この時メソッドチェーンで複数の条件を指定することができますが、これを実現しているのがRealmQueryというクラスです。

Realm.where(任意のクラス)を実行すると戻り値としてRealmQueryが返されます。RealmQueryにはequalTobetweenなどの条件指定メソッドがあり、それらを指定した後でクエリ実行メソッド(findAll() or findFirst())を実行することで、指定した条件に合ったデータを取得するという仕組みです。

ループ中でRealmQueryを使いまわすと正常な値が取得できない

さて、ここからが本題です。Realmでデータを取得する場合は上記のようにRealmQueryインスタンスのfindAll()などのクエリ実行メソッドを実行すればいいんですが、一度取得したRealmQueryインスタンスは使い回すことができません

RealmQueryインスタンスを取得してそのインスタンスのクエリ実行メソッドを実行したら、以降はそのインスタンスから値を取得することはできません。具体的には以下のコードを見てください。

private void createBookShelf() {
    Realm r = Realm.getDefaultInstance();

    // トランザクション開始
    r.executeTransaction(realm -> {
        Book b = r.createObject(Book.class);
        b.setTitle("ハンターxハンター");
        b.setAuthor("冨樫義博");
        b.setPages(200);

        Book b2 = r.createObject(Book.class);
        b2.setTitle("幽遊白書");
        b2.setAuthor("冨樫義博");
        b2.setPages(200);

        Book b3 = r.createObject(Book.class);
        b3.setTitle("ピープルウェア");
        b3.setAuthor("Tom DeMarco");
        b3.setPages(158);

        Book b4 = r.createObject(Book.class);
        b4.setTitle("アドレナリンジャンキー");
        b4.setAuthor("Tom DeMarco");
        b4.setPages(267);

        Book b5 = r.createObject(Book.class);
        b5.setTitle("熊とワルツを");
        b5.setAuthor("Tom DeMarco");
        b5.setPages(238);

        Book b6 = r.createObject(Book.class);
        b6.setTitle("ドラゴンボール");
        b6.setAuthor("鳥山明");
        b6.setPages(200);

        Book b7 = r.createObject(Book.class);
        b7.setTitle("ヒカルの碁");
        b7.setAuthor("小畑健");
        b7.setPages(200);

        Book b8 = r.createObject(Book.class);
        b8.setTitle("ヒカルの碁");
        b8.setAuthor("小畑健");
        b8.setPages(200);

        Book b9 = r.createObject(Book.class);
        b9.setTitle("レベルE");
        b9.setAuthor("冨樫義博");
        b9.setPages(200);

        RealmList<Book> bookList = new RealmList<>();
        bookList.add(b);
        bookList.add(b2);
        bookList.add(b3);
        bookList.add(b4);
        bookList.add(b5);
        bookList.add(b6);
        bookList.add(b7);
        bookList.add(b8);
        bookList.add(b9);

        BookShelf shelf = r.createObject(BookShelf.class);
        shelf.setBooks(bookList);
    });
    r.close();
}

上記のメソッドを実行すると複数のBookデータがRealmに保存されます。次にデータの消去を試してみます。ここでは漫画とデマルコ本が混ざってしまったため、漫画を削除してみましょう。Bookクラスには漫画かどうかを示すメンバがないため、著者名(author)が漫画家の場合は該当データを削除するという方向でやってみることにします。

まずは以下のコードを確認してください。

private int deleteByLoop() {
    Realm r = Realm.getDefaultInstance();

    r.beginTransaction();

    // Bookデータ全体に対するクエリ
    RealmQuery<Book> bookQuery = r.where(Book.class);

    // 漫画家名の配列
    String[] authors = {"冨樫義博", "鳥山明", "小畑健"};
    int count = 0;
    for (String author : authors) {
        Log.d("AAA", "author = " + author);

        // Bookデータのauthorがいずれかの漫画家だった場合、その漫画家のデータを全て取得する
        RealmResults<Book> books = bookQuery.equalTo("author", author).findAll();
        if (books.size() > 0) {  // データが見つかれば1件以上のリストとなる
            Log.d("AAA", author + "'s books deleted");
            books.deleteAllFromRealm();
            count++;
        }
    }
    r.commitTransaction();

    Log.d("AAA", "count = " + count);
    return count;
}

上記のコードはauthorsをループで回し、各値に該当するauthorを持つBookを取得してRealmから削除することを意図したコードです。しかし上記のコードでは最初のauthorに該当するデータしか削除されません。実際に上記を実行すると以下のログが出力されます。

07-17 01:27:36.127 27032-27032/com.example.realmsample D/AAA: author = 冨樫義博
07-17 01:27:36.130 27032-27032/com.example.realmsample D/AAA: 冨樫義博's books deleted
07-17 01:27:36.130 27032-27032/com.example.realmsample D/AAA: author = 鳥山明
07-17 01:27:36.131 27032-27032/com.example.realmsample D/AAA: author = 小畑健
07-17 01:27:36.142 27032-27032/com.example.realmsample D/AAA: count = 1

ログを見るとわかるようにauthorsの1個目のデータしか削除できていません。これはループ内での2回目以降のbookQuery.equalTo("author", author).findAll();の呼び出しで値が取得できていないことが原因です。

一通り調べてみたところこの挙動はドキュメントにも特に記載がないようですが、RealmQueryインスタンスに対して複数回クエリ実行メソッドを呼び出しても、最初の1回しかデータの取得は行われず特にエラーも発生しません

先ほどのコードを意図通りに実行するためには以下のように変更する必要があります。

private int deleteByLoop() {
    Realm r = Realm.getDefaultInstance();

    r.beginTransaction();
    RealmQuery<Book> bookQuery = r.where(Book.class);

    String[] authors = {"冨樫義博", "鳥山明", "小畑健"};
    int count = 0;
    for (String author : authors) {
        Log.d("AAA", "author = " + author);

        // 変更前
        // RealmResults<Book> books = bookQuery.equalTo("author", author).findAll();        

        // 変更後
        RealmResults<Book> books = r.where(Book.class).equalTo("author", author).findAll();

        if (books.size() > 0) {
            Log.d("AAA", author + "'s books deleted");
            books.deleteAllFromRealm();
            count++;
        }
    }
    r.commitTransaction();

    Log.d("AAA", "count = " + count);
    return count;
}

RealmQueryインスタンスを使い回すのではなくループのたびに一から取り直すように変更しました。上記のコードを実行すると以下のログが出力されます。

07-17 02:06:47.898 1651-1651/com.example.realmsample D/AAA: author = 冨樫義博
07-17 02:06:47.899 1651-1651/com.example.realmsample D/AAA: 冨樫義博's books deleted
07-17 02:06:47.899 1651-1651/com.example.realmsample D/AAA: author = 鳥山明
07-17 02:06:47.899 1651-1651/com.example.realmsample D/AAA: 鳥山明's books deleted
07-17 02:06:47.899 1651-1651/com.example.realmsample D/AAA: author = 小畑健
07-17 02:06:47.900 1651-1651/com.example.realmsample D/AAA: 小畑健's books deleted
07-17 02:06:47.906 1651-1651/com.example.realmsample D/AAA: count = 3

今度は意図したとおりauthorsの各値に対応するデータが削除できています。

まとめ

  • ループ内でRealmQueryを使い回すと2回め以降のデータ取得は失敗する。
  • ドキュメントにも失敗するとか書いてない。
  • RealmQueryを使いまわしてデータの取得に失敗しても特にエラーは発生しない。
  • ループ内でRealmのDBから検索する場合はRealm.where(~)で一から取得する必要がある。
  • そもそもクエリオブジェクトだから使い回そうという発想が間違い?
  • findFirst(), findAll()のソースを確認してみたら何かわかるかも。

*1:findFirst()なら単一のEが直接取得できる