遥かへのスピードランナー

シリコンバレーでAndroidアプリの開発してます。コンピュータービジョン・3D・アルゴリズム界隈にもたまに出現します。

数値キーで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ではとりあえず我慢、でしょうか。。。