数値キーでCursorJoinerを使う
android.databaseパッケージにCursorJoinerというクラスがあります。これは何かというと2つの異なるCursorのデータをJoinっぽく扱うことができるクラスで、たとえばContent Provider同士のデータをJoinさせてあげるときに使うことができます。
使い方は、リファレンスにある通り、
CursorJoiner joiner = new CursorJoiner(cursorA, keyColumnsofA, cursorB, keyColumnsofB); for (CursorJointer.Result joinerResult : joiner) { switch (joinerResult) { case LEFT: // handle case where a row in cursorA is unique break; case RIGHT: // handle case where a row in cursorB is unique break; case BOTH: // handle case where a row with the same key is in both cursors break; } }
みたいな感じで使うわけですが、実際こいつが何をやってるかというと、2つのカーソルのJoin対象のキーカラムを比較して、小さい方のカーソルを順番に進めていって、イテレーションのループの中で両方のカーソルに存在するデータなのか、どちらか一方のカーソルにしか存在しないデータなのかの情報を提供してあげてるだけです。
使いどころは結構あって、AndroidのOSが持っているデータベースは、基本的には直接データベースをSQLで触ることができずContent Provider経由で触ることになるので、自前のDBとOSのDB(あるいは他アプリのDB)をJoinさせたいときは、このCursorJoinerを使って擬似的にJoinしてあげるのが、ベターなのかなあと思ってます。
既存のCursorJoinerの問題点
こんな感じで割と便利なCursorJoinerですが、一つ問題があります。
エンジニア脳: android CursorJoinerでも触れられていますが、両カーソルのキー値を比較するときに、キーが数値型であっても、文字列比較してしまっているという点です。
これはどういうことかというと、たとえばCursorAのキーが1,5,10と並んでいて、CursorBのキーが1,10,20と並んでいるときに、キー=1のデータはResult.BOTH(両方のカーソルにあるデータ)として取得できるのですが、両Cursorを順番に進めていく過程で、CursorA=5,CursorB=10の時に5>10と判定してしまって、小さい(と判断された)CursorBを20に進めてしまうのです。
そのため、CursorA=10,CursorB=10となるタイミングがなくなって、キー=10はResult.BOTHで取得することができません。
キー値は大体Long型であることが多いのでこれは困ります。
というわけで、数値型のキーを扱えるようにCursorJoinerを修正してみます。
(不幸なことにCursorJoinerはfinalなクラスで、継承できないので、CursorJoinerをマルっとコピーして必要な部分だけ修正します。)
CursorJoiner修正版
import java.util.Iterator; import android.database.Cursor; /** * CursorJoinerクラスがもつ、Int型のキー同士でJoinさせる際の問題点を修正したクラス */ public final class CursorJoinerWithIntKey implements Iterator<CursorJoinerWithIntKey.Result>, Iterable<CursorJoinerWithIntKey.Result> { private Cursor mCursorLeft; private Cursor mCursorRight; private boolean mCompareResultIsValid; private Result mCompareResult; private int[] mColumnsLeft; private int[] mColumnsRight; private int[] mValues; public enum Result { RIGHT, LEFT, BOTH } public CursorJoinerWithIntKey(Cursor cursorLeft, String[] columnNamesLeft, Cursor cursorRight, String[] columnNamesRight) { if (columnNamesLeft.length != columnNamesRight.length) { throw new IllegalArgumentException("you must have the same number of columns on the left and right, " + columnNamesLeft.length + " != " + columnNamesRight.length); } mCursorLeft = cursorLeft; mCursorRight = cursorRight; mCursorLeft.moveToFirst(); mCursorRight.moveToFirst(); mCompareResultIsValid = false; mColumnsLeft = buildColumnIndiciesArray(cursorLeft, columnNamesLeft); mColumnsRight = buildColumnIndiciesArray(cursorRight, columnNamesRight); mValues = new int[mColumnsLeft.length * 2]; } public Iterator<Result> iterator() { return this; } private int[] buildColumnIndiciesArray(Cursor cursor, String[] columnNames) { int[] columns = new int[columnNames.length]; for (int i = 0; i < columnNames.length; i++) { columns[i] = cursor.getColumnIndexOrThrow(columnNames[i]); } return columns; } public boolean hasNext() { if (mCompareResultIsValid) { switch (mCompareResult) { case BOTH: return !mCursorLeft.isLast() || !mCursorRight.isLast(); case LEFT: return !mCursorLeft.isLast() || !mCursorRight.isAfterLast(); case RIGHT: return !mCursorLeft.isAfterLast() || !mCursorRight.isLast(); default: throw new IllegalStateException("bad value for mCompareResult, " + mCompareResult); } } else { return !mCursorLeft.isAfterLast() || !mCursorRight.isAfterLast(); } } public Result next() { if (!hasNext()) { throw new IllegalStateException("you must only call next() when hasNext() is true"); } incrementCursors(); assert hasNext(); boolean hasLeft = !mCursorLeft.isAfterLast(); boolean hasRight = !mCursorRight.isAfterLast(); if (hasLeft && hasRight) { populateValues(mValues, mCursorLeft, mColumnsLeft, 0 /* start filling at index 0 */); populateValues(mValues, mCursorRight, mColumnsRight, 1 /* start filling at index 1 */); switch (compareInts(mValues)) { case -1: mCompareResult = Result.LEFT; break; case 0: mCompareResult = Result.BOTH; break; case 1: mCompareResult = Result.RIGHT; break; } } else if (hasLeft) { mCompareResult = Result.LEFT; } else { assert hasRight; mCompareResult = Result.RIGHT; } mCompareResultIsValid = true; return mCompareResult; } public void remove() { throw new UnsupportedOperationException("not implemented"); } private static void populateValues(int[] values, Cursor cursor, int[] columnIndicies, int startingIndex) { assert startingIndex == 0 || startingIndex == 1; for (int i = 0; i < columnIndicies.length; i++) { values[startingIndex + i * 2] = cursor.getInt(columnIndicies[i]); } } private void incrementCursors() { if (mCompareResultIsValid) { switch (mCompareResult) { case LEFT: mCursorLeft.moveToNext(); break; case RIGHT: mCursorRight.moveToNext(); break; case BOTH: mCursorLeft.moveToNext(); mCursorRight.moveToNext(); break; } mCompareResultIsValid = false; } } private static int compareInts(int... values) { if ((values.length % 2) != 0) { throw new IllegalArgumentException("you must specify an even number of values"); } for (int index = 0; index < values.length; index += 2) { int comp = values[index] - values[index + 1]; if (comp != 0) { return comp < 0 ? -1 : 1; } } return 0; } }
これで、数値型のキーでCursorをJoinさせることができるようになります。
(ただこいつを使ってしまうと、今度は逆に文字列キーのJoinができなくなってしまうので、注意する必要がありますが)
おまけ MatrixCursor
CursorJoinerを使って、両カーソルに存在するデータだけを新たなカーソルとして取得したいときは、僕はMatrixCursorを使ってます。
他で使っているCursorAdapterを使い回したいときとかに便利です。
MatrixCursor result = new MatrixCursor(COLUMNS_OF_A); CursorJoinerWithIntKey joiner = new CursorJoinerWithIntKey(cursorA, keyColumnsofA, cursorB, keyColumnsofB); for (CursorJointerWithIntKey.Result joinerResult : joiner) { switch (joinerResult) { case BOTH: String[] values = cursor2values(cursorA); // 注:cursor2valuesはCursorをString[]に変換する独自のメソッドです。 result.addRow(values); break; } } return result;
まとめ
- ContetProvider同士のデータをJoinさせたいときはCursorJoinerが使える。
- CursorJoinerはそのままでは数値型のキーをJoinして使えないが、今回の修正版を使うことでそれができるようになる。
- CursorJoinerを使った結果をCursorで取得したいときはMatrixCursorが使える。
という感じでした。
データをJoinして扱いたい場合、パフォーマンスに拘った方法でやろうとすると、現状では上記が精一杯の対応なのではないかと思います。
実際にSQLでJoinされている訳ではないので、大量のデータを扱いたい場合はまだまだな感がありますが、現行のAndroid OSではとりあえず我慢、でしょうか。。。