
Android Roomで動的クエリを扱う

IT技術

Roomで動的クエリを使いたい時だってある
(株)ライトコードでモバイルアプリケーションメインで色々開発している笹川(ささがわ)です!
Androidアプリ開発でRoomを使っていると、@Query
アノテーションで静的なSQLを定義する場面が多いですよね。
ほとんどのケースではこれで十分なのですが、アプリの機能が複雑になってくると、次のような要望が出てくることがあります。
- ユーザーの入力に応じて検索条件を柔軟に変えたい
- 特定の条件を満たすデータだけを動的に絞り込みたい
LIKE
やGLOB
など、より高度な文字列検索をカスタマイズしたい
このような場合、事前に定義された @Query
だけでは対応が難しいことがあります。
そこで今回は、Kotlinのコード内でSQL構文を動的に構築し、Roomで実行するための方法について、具体的な手順とともに解説していきます。
なぜ動的なSQLクエリが必要になるのか
Roomの @Query
アノテーションは非常に便利ですが、SQL文自体は固定です。
例えば、ユーザーが入力したキーワードの数や種類によって WHERE
句の条件を増やしたり、並び順を動的に変更したりするようなケースでは、静的な@Query
だけでは対応しきれません。
私自身も、検索機能の実装で「複数のキーワードでAND検索したい」「特定の状態のデータをフィルタリングしたい」といった要件に直面しました。
最初は複数の@Query
を用意する方法を考えましたが、条件の組み合わせが増えるほど@Dao
インターフェースが肥大化してしまい、管理が難しくなるという課題がありました。
この経験から、KotlinコードでSQLを組み立てて動的に実行するというアプローチの重要性を痛感しました。
これにより、複雑な検索条件にも柔軟に対応でき、コードの重複を減らすことにも繋がります。
Roomで動的クエリを扱う主役:@RawQueryアノテーション
Roomで動的にSQLクエリを実行するために使うのが、@RawQuery
アノテーションです。これは、任意のSQLクエリ文字列を受け取って実行できる、強力な機能です。
RawQueryの使い方
まず、@Dao
インターフェースに@RawQuery
を付与したメソッドを定義します。このメソッドは、引数としてSupportSQLiteQuery
型のオブジェクトを受け取ります。
1interface MyDao {
2 /*
3 * 動的に構築されたクエリを実行する例
4 * @param query SQLクエリを保持するSupportSQLiteQueryオブジェクト
5 */
6 @RawQuery
7 suspend fun executeDynamicQuery(query: SupportSQLiteQuery): List<MyEntity>
8
9 // 必要に応じて、他のRoomメソッドも定義
10 // @Query("SELECT * FROM my_entities WHERE id = :id")
11 // suspend fun getById(id: Long?): MyEntity? // 例としてNullableも追加
12}
ここで重要なのは、executeDynamicQuery
メソッドがSupportSQLiteQuery
を受け取る点です。このSupportSQLiteQuery
のインスタンスをKotlinコード内で生成し、動的にSQLクエリを構築していきます。
KotlinコードでSQL構文を動的に構築する手順
それでは、具体的なSQL構文の構築手順を見ていきましょう。今回は、複数のキーワードでAND検索を行う例を考えます。
1. 検索キーワードの準備と制約チェック
まず、ユーザーから入力された検索キーワードを処理します。
全角スペースを半角スペースに変換したり、複数のキーワードに分割したり、不要な空白を除去するといった前処理が考えられます。
また、検索キーワードの数に上限を設けることも重要です。
これは、SQLiteのクエリパラメータ数の上限(デフォルト999個)など、データベースの制約に起因するパフォーマンス問題やエラーを防ぐためです。
1// 例: ユーザー入力から取得したキーワード文字列
2val userInputKeyword = "Kotlin Room Android"
3
4val queryKeywords = if (userInputKeyword.isNotEmpty()) {
5 userInputKeyword.replace(" ".toRegex(), " ") // 全角スペースを半角に
6 .split(" ".toRegex()) // 半角スペースで分割
7 .filter { it.isNotBlank() } // 空白のキーワードを除去
8} else emptyList()
9
10// SQLiteのパラメータ数上限などを考慮し、キーワード数に上限を設ける
11val KEYWORD_LIMIT_SIZE = 10 // 例えば、最大10個のキーワードを許容
12if (queryKeywords.size > KEYWORD_LIMIT_SIZE) {
13 // ここでエラーを通知したり、処理を中断する
14 // throw CustomKeywordLimitExceededException("キーワードが多すぎます。最大${KEYWORD_LIMIT_SIZE}個までです。")
15 println("キーワードが上限を超えています。検索条件を絞ってください。")
16 return // あるいはエラーを返す
17}
2. SQLクエリの基本構文を組み立てる
次に、検索キーワードの数に応じて、動的にWHERE
句を組み立てていきます。
今回は、LIKE
句とGLOB
句を組み合わせて、より厳密なキーワード検索(大文字・小文字を区別したり、単語の区切りを考慮したり)を行う例です。
ポイントは、文字列結合ではなく、プレースホルダ(?
)と引数リストを使用することです。
後述しますが、これはSQLインジェクションを防ぐ上で非常に重要です。
1val baseQuery = StringBuilder("SELECT * FROM my_entities WHERE 1=1 ") // 常にtrueの条件から開始
2
3val selectionArgs = mutableListOf<Any>() // SQLクエリのプレースホルダに対応する引数リスト
4
5// 各キーワードをAND条件で結合していく
6queryKeywords.forEach { keyword ->
7 // 例えば、item_bodyカラムを検索対象とする
8 baseQuery.append("AND item_body LIKE ? AND item_body GLOB ? COLLATE BINARY ")
9 selectionArgs.add("%$keyword%") // LIKE句用
10 selectionArgs.add("*$keyword*") // GLOB句用
11}
12
13// 必要に応じて、その他の条件を追加
14// 例: 特定のユーザーIDに紐づくデータだけを検索
15val userId = 123L
16baseQuery.append("AND user_id = ? ")
17selectionArgs.add(userId)
18
19// 並び順なども動的に追加可能
20baseQuery.append("ORDER BY created_at DESC")
21
22val finalSql = baseQuery.toString()
ここでCOLLATE BINARY
は、SQLiteで大文字・小文字を区別して比較したい場合によく使われます。
【補足】LIKEとGLOB、なぜ両方使うの?
SQLの文字列検索にはLIKE
句がよく使われますが、今回の例ではGLOB
句も一緒に使っています。これはなぜでしょうか。
LIKE
句について
- 特徴: 一般的な文字列の部分一致検索に使われます。
- ワイルドカード:
%
(任意の0文字以上の文字列)と_
(任意の一文字)が使えます。- 例:
'abc' LIKE '%b%'
はtrue
(b
を含む)
- 例:
- 注意点:
- SQLiteの
LIKE
は、デフォルトでは大文字・小文字を区別しません。ただし、PRAGMA case_sensitive_like = true;
で変更は可能ですが、一般的ではありません。 %キーワード%
のようにワイルドカードを使うと、「キーワード」が単語の一部であるかどうかの区別が難しい場合があります。
- SQLiteの
GLOB
句について
- 特徴:
LIKE
とは異なるワイルドカードを使用し、より強力なパターンマッチングが可能です。 - ワイルドカード:
*
(任意の0文字以上の文字列)、?
(任意の一文字)、[]
(文字の集合)、[^]
(文字の集合以外)。 - 特徴:
GLOB
は、デフォルトで大文字・小文字を区別します。- 正規表現に近い感覚で、より厳密な単語の区切りやパターンを指定できます。例えば、
*キーワード*
と書くことで、「キーワード」という単語が含まれる場合に絞り込むことができます- ただし、これは文字列の途中にあるかどうかを見るため、完全な単語一致とは少し異なります
今回のコード例では、LIKE '%$keyword%'
で基本的な部分一致検索を行い、さらにAND item_body GLOB '*$keyword*' COLLATE BINARY
を加えることで、以下の意図があります。
GLOB
による大文字・小文字の区別を強制する (COLLATE BINARY
はこれを明示的に行います)。LIKE
よりもより厳密なパターンマッチングをしたい(例えば、単語の区切りを意識した検索など)。
これは、単なる部分一致だけでなく、検索の「質」を高める狙いがあるわけですね。
3. SimpleSQLiteQueryでクエリを実行する
最後に、構築したSQL文字列と引数リストをSimpleSQLiteQuery
のインスタンスとして@RawQuery
メソッドに渡します。
1// SimpleSQLiteQueryにSQL文字列と引数リストを渡す
2val supportSQLiteQuery = SimpleSQLiteQuery(finalSql, selectionArgs.toTypedArray())
3
4// MyDaoインターフェースの@RawQueryメソッドを実行
5val results = myDao.executeDynamicQuery(supportSQLiteQuery)
6
7// 結果の処理
8results.forEach { entity ->
9 println("Found entity: ${entity.name}")
10}
SQLインジェクション対策と注意点
動的なSQLクエリを扱う際に、最も注意しなければならないのがSQLインジェクションです。
ユーザーからの入力値を直接SQL文字列に埋め込むと、悪意のあるコードが実行されてしまうリスクがあります。
上記の例でSimpleSQLiteQuery(finalSql, selectionArgs.toTypedArray())
を使っているのは、このSQLインジェクション対策のためです。
SimpleSQLiteQuery(String sql, Object[] bindArgs)
: SQL文字列に?
(プレースホルダ)を使用し、その?
にバインドする値をbindArgs
として別途渡すことで、Room(SQLite)が安全に値を処理してくれます。
これにより、ユーザー入力が直接SQL構文として解釈されることを防ぎます。
絶対にやってはいけない例(SQLインジェクションの危険性あり):
1// 絶対にやってはいけない例!!
2val unsafeKeyword = "'; DROP TABLE users; --" // ユーザーが悪意のある文字列を入力
3val unsafeQuery = "SELECT * FROM my_entities WHERE item_body LIKE '%$unsafeKeyword%'"
4// このunsafeQueryをSimpleSQLiteQuery(unsafeQuery)のように実行すると危険
このように、ユーザー入力を直接文字列結合でSQLに含めることは避けてください。
必ずプレースホルダを利用し、SimpleSQLiteQuery
の引数として渡すようにしましょう。
Roomの@RawQueryでアプリの検索機能を柔軟に!
今回は、Android Roomで動的なSQLクエリを扱う方法について解説しました。
ユーザーの入力に応じて柔軟な検索条件を構築したい場合に、@RawQuery
が非常に役立ちます。
LIKE
とGLOB
の違いを理解し、検索の要件に合わせて使い分けることで、より質の高い検索機能を実現できます。
SQLiteの特性として、クエリパラメータ数に上限がある点も考慮しておきましょう。
このテクニックを習得すれば、より複雑でユーザーフレンドリーな検索機能やフィルタリング機能をアプリに組み込むことができるはずです。
もちろん、パフォーマンスの考慮も必要になりますので、こちらの記事も一読お勧めいたします。
https://rightcode.co.jp/blogs/52557
ライトコードでは、エンジニアを積極採用中!
ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。
採用情報へ

新潟生まれ新潟育ち本業はモバイルアプリエンジニア。 日々、猫(犬)エンジニアとして活躍中!