クエリのwhere節での記法の注意 〜NSPredicateを意識して書くべし〜【RealmSwift】


問題

final class Page: Object {
    @Persisted(primaryKey: true) var id = UUID().uuidString
    
    @Persisted var title: String
    @Persisted var isMarked = false

このようなエンティティ定義があったとして、

let pageList = List<Page>(...)
let searchText = "banana"

let results = pageList.where {
    $0.isMarked
    && $0.title.contains(searchText, options: .caseInsensitive)
}

このようなクエリを書くと、実行時にエラーが出て怒られます。

解決策

where節の中でBool型の変数を使うときには、boolValueではなくboolValue == trueというように記述するようにします。
つまり上の例では、

pageList.where {
    $0.isMarked == true // ここ
    && $0.title.contains(searchText, options: .caseInsensitive)
}

とするのが正しいです。

理由

色々と実験をすることで、このようなことがわかりました。

  • where節に指定した、クエリを表すクロージャは、その通りに実行されるわけではない
  • クエリは内部的にNSPredicateの構文に変換され、バリデーションはNSPredicateを用いて実行される。

まず、where節の定義を見てみましょう。

func `where`(_ isIncluded: ((Query<Element>) -> Query<Bool>)) -> Results<Element>

お分かりの通り、クエリのクロージャの型は、((Element) -> Bool)ではなく((Query<Element>) -> Query<Bool>))となっています。このことから、どうやらトリックがありそうです。

次に、最初に紹介した誤ったクエリを実行したときの実行時エラーのメッセージを見てみましょう。

Unable to parse the format string \"(isMarked && (title CONTAINS[c] %@))\"

NSPredicateのフォーマット文字列をパースできない」という内容なので、NSPredicateから発せられた例外でしょう。しかし書いたコードではそんなものを使っていません。よって、クエリのクロージャは、何らかの方法でNSPredicateに翻訳されていると考えるのが妥当でしょう。つまり、クエリのクロージャは、それぞれの要素をバリデートをするたびに実行されているわけではなく、同じ意味のNSPredicateに変換されてから実行されるのです。

クエリを型安全に書けたのは、Query@dynamicMemberLookup属性が付いていることによるものです。クエリの中で書いた$0.titleというのは、実際にPageオブジェクトのtitleプロパティにアクセスしているわけではありません。

$0.isMarked == true$0.isMarkedの両者とも、型の整合性は取れているので、後者のような誤った書き方でもコンパイルができてしまいます。よってNSPredicateに渡される表現は構文として不正で、実行時エラーが発生するのです。


つまり、コンパイラによってクエリの型安全は保証されますが、構文としての妥当性は保証されていません。以上のことに気をつけながら、クエリを書きましょう。

参考記事

https://qiita.com/yusuga/items/8fd531ebd8f5e72bb97b

環境

  • realm-swift v10.25.0
  • Xcode 13.3
  • macOS 12.3
  • iOS 15.4