読者です 読者をやめる 読者になる 読者になる

Umi Uyuraのブログ

Titaniumを使ったスマートフォンアプリ開発を中心に、プログラミング関連の作業ログ。

Alloy Modelの学習・その2(マイグレーション)

前回の学習内容をもとに、せっかくなのでマイグレーション機能も試してみることにしました。

umi-uyura.hatenablog.com

今回のサンプルは前回と同じプロジェクトの step2 というブランチにあります。

umi-uyura/AlloyModelStudy at step2

マイグレーションを試す場合は、まず master ブランチをビルドして動かしてから、 step2 ブランチをビルドして実行することで発動します。

マイグレーションする内容

マイグレーションとは、テーブルにカラムを追加したいといった場合に、データベースを作りなおすのではなく、データを保持したまま変更を加える方法。

というわけで、前回のプロジェクトで作成したテーブルを加工してみることにしました。

ただ、新しい項目を加えるとUIも変更しなくてはならなくなり少々面倒なので、既存データの扱いを変更することで試すことに。

前回時のデータベースの構造は以下のとおり。

内容 カラム 主キー
レコードの識別子 id INTEGER AUTOINCREMENT
メモの内容 contents TEXT - -

変更内容としては、お気に入りということで文字列の先頭につけていた * を分離する、というものにしました。また * の登録 できる数には制限をかけていなかったこともあり、その数が多いほど優先度が高い扱いにすることにしました。

そこで、マイグレーション後のテーブル構造は以下のように変更することにしました。

内容 カラム 主キー
レコードの識別子 id INTEGER AUTOINCREMENT
メモの内容 contents TEXT - -
メモの優先度 priority TEXT - -

よってマイグレーションで実施する処理としては、 contents から先頭の * 部分を切り出して、 priority へセットする、というものになります。

マイグレーション処理の実装

マイグレーションファイルの生成

マイグレーションの内容はJavaScriptで実装できます。

そのファイル名は、モデル名をベースに、マイグレーションの世代管理のための日時情報を YYYYMMDDHHmmss_ として接頭部に付与したファイルを用意します。

この雛形は、 alloy generate コマンドで生成することもできます。

$ alloy generate migration memo
[INFO] Generated migration named 201602180643382_memo

上記のコマンドを実行した例としては、 app/migrations フォルダ内に 201602180643382_memo.js が生成されます。

マイグレーションファイルの構造

生成されたマイグレーションファイルは、以下の2つの関数によって構成されています。

migration.up = function(migrator) {
};

migration.down = function(migrator) {
};

どちらも migrator というオブジェクトを引数に取っていますが、この中にデータベースオブジェクトの参照や、テーブル名などの情報、そしてデータベース操作用の関数を持っています。この migrator オブジェクトを使って、マイグレーション処理を実装します。

migrator が持っている機能は以下のとおり。

キー 説明
db Ti.Databaseインスタンス ※このインスタンスを閉じたり、別のDBを開いたりするのはNG!
dbname データベース名
table テーブル名。 config.adapter.collection_name と同じもの。
idAttribute データベースのプライマリーキーとなる属性名
createTable データベースにテーブルを作成する。Alloy Modelの columns と同じ構造でテーブル定義を渡す。
dropTable データベースからテーブルを削除する
insertRow テーブルにレコードを追加する
deleteRow テーブルからレコードを削除する

migration.up() は前回のバージョンからデータベースをアップグレードする処理を、 migration.down() は変更点をロールバックして前回のバージョンに戻す処理を実装します。

ちなみに、そもそもですが、マイグレーション機能はsql sync adapterでのみ利用することができる機能のようです。

例)新しいテーブルを追加するマイグレーション

今回は既存のテーブルに変更を加えるパターンですが、新しいテーブルを追加する場合の例です。

この場合は、 migrator.createTable()migrator.dropTable() を使ってマイグレーションおよびロールバック処理を書くようです。

Appceleratorドキュメントの例によると、以下のような感じです。

var preload_data = [
    {title: 'To Kill a Mockingbird', author:'Harper Lee'},
    {title: 'The Catcher in the Rye', author:'J. D. Salinger'},
    {title: 'Of Mice and Men', author:'John Steinbeck'},
    {title: 'Lord of the Flies', author:'William Golding'},
    {title: 'The Great Gatsby', author:'F. Scott Fitzgerald'},
    {title: 'Animal Farm', author:'George Orwell'}
];
 
migration.up = function(migrator)
{
    migrator.createTable({
        "columns":
        {
            "book": "TEXT",
            "author": "TEXT"
        }
    });
    for (var i = 0; i < preload_data.length; i++) { 
        migrator.insertRow(preload_data[i]);
    }
};
 
migration.down = function(migrator)
{
    migrator.dropTable();
};

migrator.createTable() に、モデルの columns と同じようにテーブル定義情報を渡すことで新しいテーブルが作成されるので、必要に応じてさらに新しいデータを migrator.insertRow() で突っ込んでいます。

ロールバックする場合は、そのテーブル自体を migrator.dropTable() で削除してしまえば、もとに戻せるということのようです。

カラムを追加するマイグレーション

今回のようなカラムを追加するようなマイグレーションの場合は、直接SQLを発行して処理します。

今回の場合、

  1. 優先度カラムを追加
  2. 既存データから優先度を判定してデータを更新

という処理が必要になるので、以下のような感じに実装してみました。

app/migrations/201602180643382_memo.js

migration.up = function(migrator) {
  var db = migrator.db;
  var table = migrator.table;

  migrator.createTable({
    columns: {
      'id': 'integer primary key autoincrement',
      'contents': 'text'
    }
  });

  db.execute('ALTER TABLE ' + table + ' ADD COLUMN priority STRING DEFAULT ""');

  var rows = db.execute('SELECT * FROM ' + table + ';');
  while (rows.isValidRow()) {
    var contents = rows.fieldByName('contents');

    var priorityPos = contents.lastIndexOf('*');
    if (0 > priorityPos) {
      rows.next();
      continue;
    }

    var priorityString = contents.substring(0, priorityPos + 1);
    var contentsBody = contents.substring(priorityPos + 1);

    var updateQuery = 'UPDATE ' + table +
          ' SET priority = "' + priorityString + '", contents = "' + contentsBody +
          '" WHERE id  = ' + rows.fieldByName('id') + ';';

    db.execute(updateQuery);

    rows.next();
  }
};

SQLiteDROP COLUMN が使えないので、直接テーブルからカラムを削除することができません。そこで、カラムが減った新しいテーブルを作成して、そこにデータを入れなおす必要があるようです。

最初に migrator.createTable() をしていますが、これはこのマイグレーションを実施するバージョンのアプリを新規でインストールする場合、まだテーブルが作成されていない可能性があるからです。 migrator.createTable() は内部で IF NOT EXISTS を使っているので、すでにテーブルがある状態で呼び出しても問題ありません。

その後、 ALTER TABLEpriority カラムを追加してから、 contents の先頭にある * を取り出して priority へ格納するという処理を、すべての行に対して実施しています。

今回は関係ありませんでしたが、マイグレーション時の注意事項として、もしモデルに idAttribute を指定していない場合、 alloy_id というカラムを自動的に追加しているらしので、これを含めるのを忘れないようにする必要があるようです。

contents から * を取り除いたので、画面側にも priority を表示するため、ListItemの表示内容にも priority を追加しました。

app/views/index.jade

ListItem(title="{priority} {contents}", itemId="{id}")/

ロールバックする( priority カラムがない状態に戻す)

さて、マイグレーションが想定通り動くようになったので、次に migration.down()ロールバック処理を実装してみることに。

これは、データベースをマイグレーション前に戻す処理になります。

そこで、テンポラリのテーブルを作って、既存のデータを避難させ、改めてマイグレーション前の構造のテーブルを作成し、そこにデータを加工しつつ投入する、という感じで実装してみました。

migration.down = function(migrator) {
  var db = migrator.db;
  var table = migrator.table;
  var backup_table = table + '_backup';

  db.execute('CREATE TEMPORARY TABLE ' + backup_table + '(id,contents,priority);');
  db.execute('INSERT INTO ' + backup_table + ' SELECT id,contents,priority FROM ' + table + ';');

  migrator.dropTable();
  migrator.createTable({
    columns: {
      'id': 'integer primary key autoincrement',
      'contents': 'text'
    }
  });

  var rows = db.execute('SELECT * FROM ' + backup_table + ';');
  while (rows.isValidRow()) {
    var id = rows.fieldByName('id');
    var contents = rows.fieldByName('contents');
    var priority = rows.fieldByName('priority');

    var updateQuery = 'INSERT INTO ' + table + ' (id,contents) VALUES (' + id + ',"' + priority + contents + '");';
    db.execute(updateQuery);

    rows.next();
  }

  db.execute('DROP TABLE ' + backup_table + ';');
};

で、ここまで実装して、ふと気付いたのですが、

・・・ロールバックってどうやって確認するんだろ?

声(ツイート)に出てた。

Appceleratorのドキュメントにもそのあたり発見できず、いろいろ探していたところ、最近Q&AフォーラムがAppcelerator公式サイトから移行したStack Overflowの方で、それらしい回答を発見しました。

appcelerator - Lifecycle of Alloy Migrations - Stack Overflow

それによると、 migration.down() は何らかの方法で古いバージョンのアプリへアップグレード?したときに発動するもよう。

ただし、App Storeで公開しているものでは発動しないみたいで、これはアプリをダウングレードする手段が提供されていないから、ということかもしれません。

よって使える場面としては、テスト中やAd Hoc/Enterprise配布のときということになりそうです。

確かに本番リリースしたアプリでロールバックさせるような状況って思いつかない(必要なら再度アプリをアップデートする)ので、あまり使う機会ないのかも。

ついでに表示順序も改良

どうせ優先度をつけるなら、ListViewへの表示順序も優先度が高いとしているものから並ばせたい、と思ったので、その部分も合わせて変更することにしました。

Backbone.Collectionは、基本的にモデルを追加した順序が基準になるようです。

そこで、 priority の数が多い順が基準となるように、 Collection.comparator を実装しました。

app/models/memo.js

exports.definition = {
  ...
  extendCollection: function(Collection) {
      comparator: function(data) {
        return (0 - data.get('priority').length);
      }
    });

    return Collection;
  }
};

これで、 model.fetch() してきたときに priority 順で表示されるようになりました。

もう1点、お気に入りの登録をして priority が変化した時には、 Collection.sort() を呼び出して再度並び替えを実施することで、更新後のお気に入り数に応じた順序で表示されるようになりました。

app/controllers/index.js

function clickItem(e) {
  var optionDialog = Ti.UI.createOptionDialog(opt);
  optionDialog.addEventListener('click', function(e) {
    switch (e.index) {
    case 0:
      var m = memos.where({id: itemId});
      if (m.length) {
        var priority = '*' + m[0].get('priority');
        m[0].set({priority: priority});
        m[0].save();
        memos.sort();       // <= 追加
      }
      break;
    }
  });
  optionDialog.show();
}

Data Bindingのおかげで、UI部分を操作するコードは書くことなく、Collectionの順序が画面に反映されました。

f:id:umi-uyura:20160222233635p:plain f:id:umi-uyura:20160222233650p:plain

見た目的には前回と変わっていないのですが、お気に入り( * )の数が多いものが、より上位に表示されるようになりました。また、お気に入りの数を増やした場合、より上位に移動します。

感想

今のところマイグレーションを使うような予定はないのですが、そういうのもあるのか、ということで。

参考

Appcelerator Titanium Smartphone App Development Cookbook - Second Edition

Appcelerator Titanium Smartphone App Development Cookbook - Second Edition