2015年の11月から開発を続けているAndroid用O/R Mapper Ormaですが、このほどv1.0.0をリリースしたので入門記事を書きました*1。
UPDATE: この記事の対象はv4.1.0です。最新版はリポジトリでご確認ください。
関連エントリ: ActiveAndroidからOrmaに移行するための4つのステップ - Islands in the byte stream
Table of Contents
- Table of Contents
- Ormaとは何か
- 導入
- モデルクラスの定義
- データベースハンドル OrmaDatabase
- CRUD操作
- ほかのモデルクラスとの関連(associations)
- ほかのクラスの埋め込み(embedded objects)
- RxJava 2.0 Integration
- マイグレーション
- ToDo
Ormaとは何か
OrmaはAndroid用のORMです。実装としては SQLiteDatabase
のラッパーです。ActiveAndroidのように簡単で、GreenDAO のように高速なORMを目指して開発しています。
開発の動機や特徴は AndroidのORMに求めること、あるいはOrmaを開発した話 を参照のこと。
パフォーマンスについては 天下一「AndroidのORM」武道会(2015年版)を参照のこと。これによれば、2015年12月の時点では、SQLiteDatabase
のラッパーORMとしてはGreenDAO, DBFlowに匹敵する速度を誇ります。またOrma内のベンチマークによれば、RealmやSQLiteOpenHelper
による手書きのコードにも引けをとりません。
速度以外では、次の2つが大きな強みだと考えています。
1. SchemaDiffMigration
による自動マイグレーション
DBのマイグレーションはどの環境でも大きな悩みの種です。大抵のORMはマイグレーション機構を備えていますが、いずれも独特のノウハウが必要です。
Ormaはこの点を解消するため、SQLiteのDDL (Data Definition Language; CREATE TABLE
+ CREATE INDEX
)をパースし、差分を自動的に検出してマイグレーションのためのSQLを発行する仕組みを持っています。あらゆるケースに対応できるわけではありませんが、多くのケースで安全にマイグレーションが可能となっています。
具体的には、以下のパターンは機械的に対応できます。
- テーブルの追加
- カラムの追加
- カラムの削除
- カラムの制約(e.g.
UNIQUE
)やデフォルト値の変更
以下のパターンは対応できないため、コードでマイグレーションステップを記述する必要があります。
- テーブルのリネーム
- カラムのリネーム
2. 補完に優しく型安全なインターフェイス
Ormaはコンパイル時にJavaのソースコードを生成します。これの生成したヘルパクラスが直接操作するインターフェイスとなるのです。
これにより、たとえばActiveAndroidであれば次のように書くところを:
public static List<Item> getAll(Category category) { return new Select() .from(Item.class) .where("Category = ?", category.getId()) .orderBy("Name ASC") .execute(); }
Ormaでは次のように書きます。
public static List<Item> getAll(OrmaDatabase orma, Category category) { return orma.selectFromItem() .categoryEq(category) .orderByNameAsc() .toList(); }
ActiveAndroidだとクラスインスタンスや文字列を渡しているところがすべてメソッド名として存在していますね。これらのメソッドは補完の対象になるため、typoすることなくモデルクラスの操作ができるのです。またこれにより、型安全なプログラミングが可能になり、コンパイル時に多くの誤りを検出できるようになっています。
導入
annotation processorとランタイムライブラリからなるので、それを両方ともdependencies
ブロックで宣言します。
// app/build.gradle dependencies { compile 'com.github.gfx.android.orma:orma:4.1.0' annotationProcessor 'com.github.gfx.android.orma:orma-processor:4.1.0' }
これでOrmaを使えるようになります。
モデルクラスの定義
Ormaでは、テーブルに対応するスキーマはPOJOにアノテーションを付加したものです。Ormaのannotation processorがアノテーションをみてヘルパークラスを生成します。
スキーマを定義するには、次のようにモデルクラスを@Table
, @PrimaryKey
, @Colum
アノテーションで注釈します。カラムはデフォルトで NOT NULL
になるので、NULLを許容する場合は @Nullable
アノテーションが必要だということに注意してください。
@Table public class Todo { @PrimaryKey(autoincrement = true) public long id; @Column(indexed = true) public String title; @Column @Nullable // allows NULL (default: NOT NULL) public String content; @Column(indexed = true) public boolean done; }
モデルのための制約はほとんどなにもないので、任意のインターフェイスを実装したりベースクラスを継承してしてかまいません。モデルに求められる条件は以下のいずれかです。
- 空の
public
コンストラクタがあること - カラムのすべてを受け取るコンストラクタがあること(コンストラクタに
@Setter
の付加が必要)
もちろんモデルクラスをSerializable
やParcelable
にすることもできます。Ormaはモデルに対して特別なことを要求しないようにしており、次のようにすべてのフィールドをfinal
にした不変クラスにすることすらできます。なおこの場合はコンストラクタに @Setter
アノテーションが必要です。
@Table public class Todo { @PrimaryKey(autoincrement = true) public final long id; @Column(indexed = true) public final String title; @Column @Nullable public final String content; @Column @Column(indexed = true) public final boolean done; @Setter public Todo(long id, String title, String content, boolean done) { /* ... */ } }
@PrimaryKey
のオプション
@PrimaryKey
には2つのオプションがあります。
boolean auto
(defaultはtrue
) - PKをSQLiteに生成させたくない場合はfalse
にしてくださいboolean autoincrement
(defaultはfalse
) -AUTOINCREMENT
の付加の有無です。これを有効にするとPKが単調増加するようになり、ソートが意味のある操作になります
サーバーサイドから取得した値にidが含まれるのであれば、 auto = false
にして外からidを与えるようにしてください。
@Column
のオプション
@Column
には複数のオプションがあります。ここでは代表的なオプションを紹介します。
boolean indexed
(defaultはfalse
)- インデクスが作られて、クエリビルダに該当カラム用のヘルパが生成されます。たとえば@Column(indexed = true) String title
とすると、後述するSelector
やRelation
などにtitleEq()
,titleIn()
などのメソッドが追加されます。boolean unique
(defaultはfalse
) - UNIQUE制約がつけられます。なお@PrimaryKey
はSQLの仕様で自動的にUNIQUEになりますString defaultExpr
- カラム定義のときにDEFAULT
句を追加します。これが有効なのは新しくカラムを追加するときです。NOT NULL
なカラムを新しく追加するときは、自動マイグレーションのために必ずこれが必要となっています。
その他のオプションについては @Column
を参照してください。
データベースハンドル OrmaDatabase
モデルを定義したら一度ビルドしてください。するとOrmaDatabase
やTodo_Relation
などのヘルパクラスが生成されて参照できるようになります。
Ormaの操作は OrmaDatabase
クラスのインスタンス経由で行います。これをDagger2などのDIで @Singleton
にしておくことをおすすめします。
@Singleton @Provides public OrmaDatabase provideOrmaDatabase(Context context) { return OrmaDatabase.builder(context) .build(); }
デフォルトの設定だと、デバッグビルド時に実行するSQLをlogcatに出力したりメインスレッドでの書き込みで例外を投げたりします。これらはbuilderの設定で変更できます。
CRUD操作
さて、データベースハンドルを作れたらCRUD操作をしましょう。OrmaのCRUD操作には2つの体系があります。
1. Inserter, Selector, Updater, Deleterヘルパを使った生のSQLに近い操作
これらは Inserter<Todo>
, Todo_Selector
, Todo_Updator
, Todo_Deleter
のような名前で参照します。
Inserter<Todo>
の実体はプリペアドステートメントで、次のように使います。
OrmaDatabase orma = ...; Todo todo = ...; // 単発でINSERTするだけならこれで orma.insertIntoTodo(todo); // 何度もINSERTするならInserter<T>経由で Inserter<Todo> inserter = orma.prepareInsertIntoTodo(); inserter.execute(todo); inserter.executeAll(Arrays.asList(todo)); // RxJavaのSingle<T>で実行スレッドを変えつつ非同期実行 inserter.executeAsObservable(todo) .subscribeOn(Schedulers.io()) .subscribe(...);
Selector
, Updater
, Deleter
はプリペアドステートメントではなくクエリビルダです。
Todo_Selector
:
Todo_Selector selector = orma.selectFromTodo() .doneEq(true) .orderByIdDesc(); // Todo_SelectorはIterableなので直接forループすることができる for (Todo todo : selector) { ... } // その他コレクションライクなメソッドたち Todo todo = selector.get(0); boolean empty = selector.empty(); int count = selector.count(); List<Todo> = selector.toList(); // Cursorを直接触ることもできる Cursor c1 = selector.execute(); // 集積関数もOK Cursor c2 = selector.executeWithColumns("max(id)", "min(id)");
Todo_Updater
:
// 特定のtodoを done = true にする orma.updateTodo() .idEq(todo.id) .done(true) .execute();
Todo_Deleter
:
// 特定のtodoを削除する
orma.deleteFromTodo()
.idEq(todo.id)
.execute();
これらのヘルパーメソッドの特徴は、SQLを想像しやすいインターフェイスであることです。しかし、通常はOrmaDatabase
を直接使うのではなく、Relation経由でこれらのクラスを生成することをおすすめします。
2. Relationヘルパ経由でモデルクラスを中心とした操作
Relation(具象クラスはTodo_Relation
など)はあるモデルに対応するヘルパークラスで、ほかのヘルパークラスを生成する窓口でもあります。このクラスは、あるモデルを操作するときのヘルパークラスの中心となることを意図しています。
また、Relationインスタンスは検索条件とソート条件を持つことができます。これにより、あるtableのサブセットであり特定の順番を持つRelationを作れます。
たとえば、Todoモデルには次のようなメソッドを定義するとします。 autoincrement = true
のとき、primary keyは単調増加するので、このrelationからリストを得るとTodoオブジェクトを新しい順に取得できます。
@Table class Todo { // ... // すべてのTodo public static Todo_Relation relation(OrmaDatabase orma) { return orma.relationOfTodo().orderByIdDesc(); } // Todoのうち、done = true なサブセット public static Todo_Relation relationInDone(OrmaDatabase orma) { return relation(orma).doneEq(true); } // Todoのうち、 done != true なサブセット public static Todo_Relation relationInNotDone(OrmaDatabase orma) { return relation(orma).doneNotEq(true); } }
RelationからはさらにInserter, Selector, Updater, Deleterを生成できます。このときSelectorはRelationの検索条件とソート条件を引き継ぎ、UpdaterとDeleterは検索条件を引き継ぎます。次のコード例を参照ください。
// 以下は orma.selectFromTodo().orderByIdDesc().toList()と同じ Todo.relation(orma).toList(); // 以下は orma.deleteFromTodo().doneEq(true).execute() と同じ Todo.relationInDone(orma).deleter().execute();
Relationはまた、 OrmaListAdapter
や OrmaRecyclerViewAdapter
を使うときに必要です。詳細は以下を参照のこと。
example/RecyclerViewFragment.java
ほかのモデルクラスとの関連(associations)
has-oneやhas-manyなどのassociationsもある程度サポートされています。
モデルの関連は、Javaのクラス的には直接保持するように見えるdirect associationsと、 SingleAssociation<T>
経由で保持するindirect associationsがあります。どちらもテーブル定義的には親が子のprimary keyを持つ実装となっているので、マイグレーションすることなくいつでも交換可能です。
他のモデルを直接保持する(direct associations)
direct associationsはあるモデルが別のモデルを直接保持する形のassociationsです。SELECT
で子のカラムも一気にとってくるため*2、場合によってはindirect associationsよりも高速です。なにより、通常のPOJOとして記述するだけなのでクセがなく使いやすいというのも特徴です。
使い方はexampleにあるCategoryとItemをみてください。この二つのモデルの関連は、 a category has many items / an item belongs to a category となっています。
has-many使いやすいとは言いがたいのですが、OrmaDatabase
をアプリケーションレベルでsingletonにするといくらか使いやすくなると思います。
SingleAssociation<T>
を使う関連(indirect associations)
indirect associationsはあるモデルが他のモデルを直接保持するのではなく、 SingleAssociation<T>
として持ちます。SELECT
のときに子のカラムを取得せず、必要になったタイミングで SELECT
を発行する*3ので、状況によってはdirect associationsよりも高速かもしれません。しかし使用感に独特のクセがあるので通常はdirect associationsのほうが扱いは簡単でしょう。
ほかのクラスの埋め込み(embedded objects)
ほかのモデルクラスとの関連ではなく、オブジェクトをシリアライズしてカラムに直接保存するという方法もあります。たとえば、java.util.Date
やandroid.net.Uri
などはこの方法で扱いますし、フィールドの検索が必要なければ任意のリソースオブジェクトを埋め込んでもいいでしょう。
type adapterはこのようなときにクラスのシリアライズとデシリアライズを行うための仕組みです。 @StaticTypeAdapter
で注釈したクラスの静的メソッドを生成されるコードに直接記述するので、ジェネリクスでは表現できない long
などのプリミティブ型も扱えます。
たとえば、Google Play Servicesの LatLng
のtype adapterは次のように実装します。
@StaticTypeAdapter( // 対象となる型 targetType = LatLng.class, // シリアライズしてどの型にするか(ほかには long, double, byte[] などを指定可能) serializedType = String.class ) public class LatLngAdapter { // nullチェックはこのメソッドを呼び出す前に行われるので不要 @NonNull public static String serialize(@NonNull LatLng source) { return source.latitude + "," + source.longitude } // nullチェックはこのメソッドを呼び出す前に行われるので不要 @NonNull public static Location deserialize(@NonNull String serialized) { String[] values = serialized.split(","); return new LatLng( Double.parseDouble(values[0]), Double.parseDouble(values[1])); } }
デフォルトで提供しているtype adapterもいくつかあります。こちらはユーザ定義とは違ってシリアライザ・デシリアライザの実装だけですが、カスタムtype adapterの実装の参考にはなると思います。
library/BuiltInSerializers.java
なおjava.util.Date
とjava.sql.{Date,Time,DateTime}
のtype adapterの差は、java.util.Date
がミリセコンドの整数にシリアライズするのに対し、java.sql.*
のtype adapterは文字列にシリアライズするという違いがあります。java.sql.DateTime
をつかうと、Stethoなどで直接DBをみたときにわかりやすいうえ、SQLiteの日付演算も行えるのが利点です。
RxJava 2.0 Integration
Ormaデフォルトの設定ではメインスレッドでの書き込み操作を禁止しています*4。これは、DB書き込みはそこそこ時間のかかる処理なので、それによってメインスレッドをロックしないためです。そこで、RxJavaインテグレーションによって処理をバックグラウンドで行うことにより、スムーズに動くアプリケーションを作成できます。
RxJavaインテグレーションの一覧はRxJava2Test.java にあるとおりです。基本的には、RxJavaのobservableファミリ(Observabel<T>
やSingle<T>
など)を返す版のメソッドが fooAsObserable()
や fooAsSingle()
という名前で提供されています。
マイグレーション
Ormaの特徴はなんといってもマイグレーションです。テーブルの追加は気軽にできますし、カラムの追加はちょっとだけコツがいりますがマイグレーションステップを手書きするよりはマシでしょう。そしてSQLiteでは地獄のように難しいカラムのリネームも、手書きでステップを記述する必要こそあるものの比較的簡単にできます。
マイグレーションの全体像はざっと migration/README.md に書いています。
またexample/MainFragment もいい例で、example appが起動するときに必ずさまざまなmigrationが走るようになっています。テーブルやカラムのリネームも行っています。このマイグレーションのログはMainActivityのスクリーンに表示します。migrationの疑問はexample appをいじると解消するかもしれません。
関係するクラスは SchemaDiffMigration
, ManualStepMigration
, OrmaMigration
です。
SchemaDiffMigration
デフォルトのmigration engineです。DDLの差分からマイグレーション用SQLを生成して実行します。特性は冒頭で述べたとおりです。
ManualStepMigration
スキーマバージョンごとのstepを定義して実行するmigration engineです。独自のスキーマバージョンと履歴管理をDB内の専用テーブルで行います。スキーマバージョンのデフォルトはアプリケーションの BuildConfig.VERSION_CODE
です。
マイグレーションステップは OrmaDatabase.Builder#migrationStep()
で定義します。
OrmaMigration
まずManualStepMigration
を呼び出し、その後SchemaDiffMigration
を呼び出すmigration engineです。builder経由で生成し、ManualStepMigration
のためのstepを定義します。migration stepはスキーマのupgradeにもdowngradeにも対応できるようになっていますが、ほとんどの場合upgradeだけ十分でしょう。migration stepでは任意のSQLを発行できますが、OrmaDatabase
はまだ初期化されていないので使えません。
OrmaDatabase.Builder
はデフォルトでSchemaDiffMigration
を使いますが、OrmaDatabase.Builder
にmigration stepを渡すと内部で使われるmigration engineがOrmaMigration
になります。
int VERSION_2; int VERSION_3; OrmaDatabase orma = OrmaDatabase.builder(context) // register change(), which is used both in upgrade and downgrade .migrationStep(VERSION_3, new ManualStepMigration.ChangeStep() { @Override public void change(@NonNull ManualStepMigration.Helper helper) { Log.(TAG, helper.upgrade ? "upgrade" : "downgrade"); helper.execSQL("DROP TABLE foo"); helper.renameTable("books", "Book"); helper.renameColumn("books", "publisherId", "publisher"); } }) .build();
スキーマのバージョニング
Orma v2.1.0 以降
Ormaは SQLiteOpenHelper
に依存しません。Orma自身は常にマイグレーションを起動し、具体的な処理はすべてmigration engineに移譲します。
OrmaMigration
は無条件にManualStepMigration
とSchemaDiffMigration
(SDM) を起動しますManualStepMigration
(MSM) はSQLiteDatabase#version
から起動するステップを判断します。MSMが使うデフォルトのバージョンはアプリケーションのBuildConfig.VERSION_CODE
です。なおMSMの挙動はSQLiteOpenHelper
と互換性があるので、これまでSQLiteOpenHelper
ベースのマイグレーションを使っている場合でもMSMに移行できますし、逆もしかりです。- SDM はDDLのSHA2-256ハッシュ(
OrmaDatabase.SHEMA_HASH
)をベースにしてマイグレーションの起動を判断します。このmigration engineは冪等なのですが、毎回走らせると時間がかかるので、このチェックは単純に不要な処理をしないようにするためです。
Orma v2.1.0 未満
Ormaは SQLiteOpenHelper
のスキーマバージョン管理機能を使ってマイグレーションを起動します。
OrmaMigration
とSDMのデフォルトの設定だと、デバッグビルドではApplicationInfo#lastUpdateTime
を分に換算した値を、リリースビルドではBuildConfig.VERSION_CODE
をスキーマバージョンとして使います。これは、インストール直後に1度だけマイグレーションを実行するためです。
いずれのmigration engineも、何度も呼び出しても問題ないようにはなっていますが、数百ミリ秒かかるので頻繁には行いたくないのです。
またそれとは別にOrmaMigration.Builder
ではMSMへのバージョンも設定できますが、v1.1.1の時点ではデフォルトで BuildConfig.VERSION_CODE
が使われるので、特に理由がない限り設定の必要はありません。
ToDo
既知の問題や今後の予定はgithub issuesで管理しています。ここにないものは(突発的に思いついて対応しないかぎり)予定にないため、なにか要望があれば教えてください。