枕を欹てて聴く

香炉峰の雪は簾を撥げて看る

Client Side Databaseについて

あんまり解説なかったのでメモ. iReader作ったときに利用した.
正直今のところSafariしか対応してないから, iPhone/iPod touch専用ページくらいしか使いどころないのですが.HTML5なのでいつかはcross-platformで役に立つことを願いつつ.

仕様

HTML Standard

interface Database {
void transaction(in SQLTransactionCallback callback, [Optional] in SQLTransactionErrorCallback errorCallback, [Optional] in SQLVoidCallback successCallback);
void readTransaction(in SQLTransactionCallback callback, [Optional] in SQLTransactionErrorCallback errorCallback, [Optional] in SQLVoidCallback successCallback);

readonly attribute DOMString version;
void changeVersion(in DOMString oldVersion, in DOMString newVersion, in SQLTransactionCallback callback, in SQLTransactionErrorCallback errorCallback, in SQLVoidCallback successCallback);
};

[Callback=FunctionOnly, NoInterfaceObject]
interface SQLVoidCallback {
void handleEvent();
};

[Callback=FunctionOnly, NoInterfaceObject]
interface SQLTransactionCallback {
void handleEvent(in SQLTransaction transaction);
};

[Callback=FunctionOnly, NoInterfaceObject]
interface SQLTransactionErrorCallback {
void handleEvent(in SQLError error);
};

http://www.whatwg.org/specs/web-apps/current-work/#databases

typedef sequence ObjectArray;

interface SQLTransaction {
void executeSql(in DOMString sqlStatement, [Optional] in ObjectArray arguments, [Optional] in SQLStatementCallback callback, [Optional] in SQLStatementErrorCallback errorCallback);
};

[Callback=FunctionOnly, NoInterfaceObject]
interface SQLStatementCallback {
void handleEvent(in SQLTransaction transaction, in SQLResultSet resultSet);
};

[Callback=FunctionOnly, NoInterfaceObject]
interface SQLStatementErrorCallback {
boolean handleEvent(in SQLTransaction transaction, in SQLError error);
};

http://www.whatwg.org/specs/web-apps/current-work/#executing-sql-statements

interface SQLResultSet {
readonly attribute long insertId;
readonly attribute long rowsAffected;
readonly attribute SQLResultSetRowList rows;
};

http://www.whatwg.org/specs/web-apps/current-work/#database-query-results

interface SQLResultSetRowList {
readonly attribute unsigned long length;
[IndexGetter] any item(in unsigned long index);
};

http://www.whatwg.org/specs/web-apps/current-work/#database-query-results

open

client side database を開く

/*
 * openDatabase(id, version, comment);
 */
var database = openDatabase('test', '1.0', 'database for test');

で, databaseがopenできる.idを同じものを指定すると当然同じdatabaseが返ってくる. 前に作ったことがある場合はそれが, 一度も作ったことがなければ新規に作成されて返る.
第三引数のコメントは省略できるけど, versionは省略できない.
また, 最初に作ったときのversionで決まっていて, version 1.0で作ったのに別のversionを指定するとerrorをはかれる.

var database = openDatabase('test', '1.0', 'database for test');
// ng
//var database = openDatabase('test', '1.1');
// ok
var database = openDatabase('test', '1.0');

versionを変えたければ, changeVersionというメソッドがあるのでそれで.

transaction async

で, 中身がSQLiteなのでいろいろするためにはtransaction処理する必要がある.これにはdatabaseのtransactionメソッドを使う.

/*
 * db.transaction(callback, success, failure);
 *   callback : transaction objectが引数として渡されるcallback関数.
 *   failure  : callback内の処理が失敗してれば呼ばれる.
 *              callback中の行為はロールバックされてる.
 *   success  : callback内の処理が成功してれば呼ばれる.
 */
var callback = function(trans){
  /* 処理 */
}
var success = function(res){
  /* 成功時に呼ばれる */
}
var failure = function(err){
  /* 失敗時に呼ばれる */
}
database.transaction(callback, failure, success);

ここで, transactionは非同期であるという話.
で, test code

var database = openDatabase('test', '1.0', 'database for test');
var fuga = 'foo';
alert(1);
database.transaction(
  function(trans){
  fuga = 'bar';
  /*
    // 時間稼ぎ
    var ary = [];
    for(var i = 0,l=1000000;i<l;i++){
    ary.push(i);
    }
    */

  //不確定
  alert('2 or 3');
  },
  function(err){
    /* 呼ばれない */
  },
  function(res){
    //確定
    alert(4);
});
//不確定
alert('2 or 3');
//不確定 fooかもbarかも
alert(fuga);

alertをコメントアウトしてみたりいろいろ試すとわかるけれど, transactionの第一引数の関数は, setTimeoutの非同期とは一味違う.
イベントみたいに, 続く処理が終わるとか終わらないとか関係なしに同時並行で実行される. それで, 上のものをいじると, 最後のalert(fuga)の結果がbarになったりfooになったりする.
保障されてるのは, transactionが終了後にしかtransactionの第二, 第三引数の関数は呼ばれないということ.
よって, transaction内の行為や, transactionを単純な非同期と思って, transaction実行後にtransaction内の処理の依存する行為をするのは避けたほうがよさげ.

けどこのなんともいえない感じが怖いので,

var database = openDatabase('test', '1.0', 'database for test');
var fuga = 'foo';
alert(1);
setTimeout(function(){
  database.transaction(
    function(trans){
    fuga = 'bar';
    //確定
    alert(3);
    },
    function(err){
      /* 呼ばれない */
    },
    function(res){
      //確定
      alert(4);
  });
}, 0);
//確定
alert(2);
//確定 foo
alert(fuga)

と, setTimeoutでくくったほうが安心.

transation scope

callbackにはtransactionオブジェクトが引数として渡される. これのメソッドからdatabaseをいじるが, transactionの性質上, transactionオブジェクトからdatabaseをいじれるのはcallback関数内のそのときのみとなる. transactionが閉じられるし.
ということで...以下はerrorのものをコメントアウトした例

var database = openDatabase('test', '1.0');
var ex_trans = null;
database.transaction(function(trans){
  ex_trans = trans;
  setTimeout(function(){
    // setTimeout なので処理が終わってから呼ばれてるから error
    // trans.executeSql('CREATE TABLE IF NOT EXISTS test (id INTEGER, name TEXT);');
  }, 0);
  // 普通にOK
  trans.executeSql('CREATE TABLE IF NOT EXISTS test (id INTEGER, name TEXT);');
}, function(err){
}, function(res){
    // callback関数の処理が終わってるから error
    // ex_trans.executeSql('CREATE TABLE IF NOT EXISTS test (id INTEGER, name TEXT);');
});

change version

わき道. changeVersionのメモ.
openDatabaseでのversionを変更するためにはchangeVersionを使う.

var database = openDatabase('test', '1.0');
/*
 * db.changeVersion(old version, new version, callback, failure, success);
 *   old version : 古いversion. 変更前.
 *   new version : 新しいversion. 変更先.
 *   callback    : callback関数. transactionのときと同じ. 引数にはtransactionオブジェクトがわたされる.
 *   failure     : transactionがerrorをはいたときに呼ばれる. transactionのときと同じ.
 *   success     : transactionが成功した後呼ばれる. transactionのときと同じ.
 */
database.changeVersion('1.0', '1.1',
  function(trans){
    //処理
  },
  function(err){
    alert(err.message);
  },
  function(){
    alert('success');
    //1.1になってる
    alert(database.version);
  }
);

executeSql

SQLはtransactionオブジェクトのexecuteSqlメソッドから実行する.
プレースホルダも使える.

var database = openDatabase('test', '1.0');
database.transaction(function(trans){
  // 存在しなければtestテーブルを作成. fieldはINTEGER型のid, TEXT型のname
  // SQLiteなので型はあんまり意味がない.
  trans.executeSql('CREATE TABLE IF NOT EXISTS test (id INTEGER, name TEXT);');
  // プレースホルダも使える
  // 第二引数に配列を指定. ?の順番に従って代入される.
  trans.executeSql('INSERT INTO test (id, name) VALUES (? , ?)', ['Constellation', 'utatane']);
}, function(err){
  alert('failure');
}, function(res){
  alert('success');
});

素のSQLを実行しちゃうのであってSQL Injectionの際に気をつけるべき点はすべて当てはまるので注意.
よってプレースホルダ万歳.

// こうされたら大変
var database = openDatabase('test', '1.0');
var id = "Constellation";
var pass = "' OR 'OK' = 'OK";
database.transaction(function(trans){
  // プレースホルダを使う. priceless
  trans.executeSql("SELECT * FROM users WHERE id = ? AND pass = ?", [id, pass]);
}, function(err){
  alert('failure');
}, function(res){
  alert('success');
});

どうしてもとか, ちょっとした処理にとか, 正直外部データが限られてるって方はエスケープでもどうぞ.
お勧めはしません.

// こうされたら大変
var database = openDatabase('test', '1.0');
var id = "Constellation";
var pass = "' OR 'OK' = 'OK";
database.transaction(function(trans){
  trans.executeSql("SELECT * FROM users WHERE id = '" + escapeSQL(id) + "' AND pass = '" + escapeSQL(pass) + "'");
}, function(err){
  alert('failure');
}, function(res){
  alert('success');
});
escapeSQL = function(text){
  text = text + '';
  return text.replace(/'/g, "''");
}

executeSql, callback

executeSqlの第三, 第四引数にはcallback関数が入る.
第四引数に関しては, 入れるか入れないかで挙動が変わる. 具体的には, executeSqlがfailure時に呼ばれるが, これが指定されているとexecuteSqlの結果がロールバックされない.

var database = openDatabase('test', '1.0');
database.transaction(function(trans){
  // これは失敗してもロールバックされる.
  trans.executeSql('CREATE TABLE IF NOT EXISTS test (id INTEGER, name TEXT);',
  //プレースホルダ用配列. これでは使ってない.
  [],
  /**
   * transはtransactionオブジェクト, resはSELECT文を使ったときなどの結果.
   */
  function(trans, res){
    alert('result');
  });
  // こちらはされない.
  trans.executeSql('CREATE TABLE IF NOT EXISTS test (id INTEGER, name TEXT);',
  [],
  function(trans, res){
  },
  function(trans, err){
    alert('ロールバックされてないよ');
  });
}, function(err){
  alert('failure');
}, function(res){
  alert('success');
});

SELECT文などの結果を受け取りたい場合はこのcallbackの第二引数から受け取るが, これは非同期. よってこんな感じに.
alertのなかの数字が, 呼ばれる順. なんとややこい.

var database = openDatabase('test', '1.0');
alert(1);
setTimeout(function(){
  database.transaction(function(trans){
    alert(3);
    trans.executeSql('CREATE TABLE IF NOT EXISTS test (id INTEGER, name TEXT)',
    trans.executeSql('SELECT * FROM test',
    [],
    function(trans, res){
      alert(5);
      /**
       * resはSELECT文の結果.
       * res.rowsに行がlike arrayで入っていて, それぞれhashになってる.
       * res.rows # =>
       *  [
       *     {
       *       id: 'Constellation',
       *       name: 'utatane'
       *     },
       *     {
       *       id: 'ニャンコ先生',
       *       name: '斑'
       *     }
       *  ]
       *  見たいな感じ. しかしitem(1)とかでlike arrayを触る必要あり.
       *  いかんせん触りづらいので適当に配列につめなおすのが吉?
       */
      // こんな感じ?
      var result = [];
      for(var i = 0, l = res.rows.length; i < l; ++i){
        result.push(res.rows.item(i));
      }
      //でまあ resultを好きに使うという.
      console.info(result);
    });
    //この時点ではSELECT文のcallbackは実行されない.非同期.
    alert(4);
  }, function(err){
  }, function(res){
    alert(6);
  });
}, 0);
alert(2);

実用

何が困るってこの非同期の多さ. JSDeferredやMochikit Deferredの利便性のよさを考えるとぜひともメソッドチェーンに収めたいところ.
注意すべきはtransaction scopeのこと. transaction scopeから抜けてしまえばtransactionを扱うことができなくなってしまうので. JSDeferredおよびMochikit DeferredのwaitはsetTimeoutを使うので, 内部で使ってしまうと以後, transactionオブジェクトを使えなくなる.
iReaderでは勝手にこのJSDeferredとMochikit Deferredの中間位のものを作って, それにSQL機能を実装してるけど, とりあえずそれをJSDeferredに持ってきました+改良しましたversion

var SQL = null;
(function(){
  SQL = Deferred.SQL = function(db, callback){
    var d = new Deferred();
    setTimeout(function(){
      db.transaction(
        function(trans){ return callback(new Transaction(trans, db)) },
        function(e){ d.fail(e) },
        function(res){ d.call(res) });
    }, 0);
    return d;
  }
  var Transaction = function(trans, db){
    this.transaction = trans;
    this.database    = db;
    this.version     = db.version;
  }
  Transaction.prototype = {
   // trans.createTable('mytable', {id: 'INTEGER', name: 'TEXT'});
    createTable: function(name, obj){
      var ret = new Deferred();
      var self = this;
      var params = [];
      for(var key in obj){
        if(obj.hasOwnProperty(key)){
          params.push(key + ' ' + obj[key])
        }
      }
      params = params.join(', ');
      this.execute('CREATE TABLE IF NOT EXISTS '+name+' ( ' + params + ' )',
        [],
        function(trans, rs){
          ret.call([(new Transaction(trans, self.database)), rs]);
        });
      return ret;
    },
    // trans.insert('mytable', {id: 1, name: 'Constellation' });
    insert: function(name, obj){
      var ret = new Deferred();
      var self = this;
      var place = [];
      var columns = [];
      var values = [];
      for(var k in obj){
        if(obj.hasOwnProperty(k)){
          columns.push(k);
          place.push(obj[k]);
          values.push('?');
        }
      }
      columns = columns.join(', ');
      values = values.join(', ');
      this.execute('INSERT INTO '+name+' ( '+columns+' ) VALUES ( '+values+' )',
        place,
        function(trans, rs){
          ret.call([(new Transaction(trans, self.database)), rs]);
        });
      return ret;
    },
    selectAll: function(name){
      var ret = new Deferred();
      var self = this;
      this.execute('SELECT * FROM '+name, [],
        function(trans, rs){
          ret.call([(new Transaction(trans, self.database)), rs]);
        });
      return ret;
    },
    select: function(name, obj){
      var ret = new Deferred();
      var self = this;
      var place = [];
      var data = [];
      for(var k in obj){
        if(obj.hasOwnProperty(k)){
          data.push('( '+k+' = ? )');
          place.push(obj[k]);
        }
      }
      data = data.join(' AND ');
      this.execute('SELECT * FROM '+name+' WHERE '+data, place,
        function(trans, rs){
          ret.call([(new Transaction(trans, self.database)), rs]);
        });
      return ret;
    },
    del: function(name, obj){
      var ret = new Deferred();
      var self = this;
      var place = [];
      var data = [];
      for(var k in obj){
        if(obj.hasOwnProperty(k)){
          data.push('( '+k+' = ? )');
          place.push(obj[k]);
        }
      }
      data = data.join(' AND ');
      this.execute('DELETE FROM '+name+' WHERE '+data,
        place,
        function(trans, rs){
          ret.call([(new Transaction(trans, self.database)), rs]);
        });
      return ret;
    },
    delAll: function(name){
      var ret = new Deferred();
      var self = this;
      this.execute('DELETE FROM '+name,
        [],
        function(trans, rs){
          ret.call([(new Transaction(trans, self.database)), rs]);
        });
      return ret;
    },
    // raw
    execute: function(){
      var args = Array.prototype.slice.call(arguments);
      var trans = this.transaction;
      trans.executeSql.apply(trans, args);
    }
  }
})();

実例としては

Deferred.define();
var db = openDatabase('test', '1.0', 'database for test');
var global = {};
alert(1);
next(function(){
  alert(2);
  return SQL(db, function(tr){
    alert(3);
    tr.createTable('config', {key: 'TEXT', value: 'TEXT'});
    tr.insert('config', {key: '1', value: 'Constellation' });
    tr.insert('config', {key: '斑', value: 'ニャンコ先生' });
    // selectからつなげられる (* wait使ったらtransactionのメソッドは使えない!)
    return tr.select('config',{key: '斑'})
      .next(function(arr){
        alert(4);
        var tr = arr[0];
        var result = arr[1];
        for(var i = 0, l = result.rows.length; i < l; i++){
          var row = result.rows.item(i);
          var key = row.key;
          var value = row.value;
          if(key && value) global[key] = value;
        }
        return tr.select('config', {key: '1'})
          .next(function(arr){
            alert(5);
            var tr = arr[0];
            var result = arr[1];
            for(var i = 0, l = result.rows.length; i < l; i++){
              var row = result.rows.item(i);
              var key = row.key;
              var value = row.value;
              if(key && value) global[key] = value;
            }
            });
      })
      .next(function(){
        alert(6);
      });
  });
})
.next(function(res){
  alert(7);
  for(var k in global){
    if(global.hasOwnProperty(k)){
      console.log(k + ' : ' + global[k]);
    }
  }
})
.next(function(){
  alert(8);
})
.error(function(e){
  alert(e.message);
});

だいぶ見やすくなったかな?
updateとか付け加えたければまあ同じ感じに付け加えられる.
もっと複雑なのをやりたかったらexecuteメソッドとしてexecuteSqlを残してるからそこからできる.
JSだしそんなViewつくったりとかはしないのでまあinsertとdeleteとselectとcreateTableがあればいいかと.

付録?

魔改造version. Deferredを渡す際に書き換え続けることでメソッドチェーンを実現できる. 多少えぐい.
お勧めはしない. たぶん上の方のやつのほうがましかも.

var SQL = null;
(function(){
  var definition = ['createTable', 'insert', 'selectAll', 'select', 'del', 'delAll'];
  var updater = function(ret){
    definition.forEach(function(name){
      ret[name] = function(){
        var args = Array.prototype.slice.call(arguments);
        return updater(this.next(function(res){
          var trans = res[0];
          var result = res[1];
          return trans[name].apply(trans, args);
        }));
      }
    });
    return ret;
  }
  SQL = Deferred.SQL = function(db, callback){
    var d = new Deferred();
    setTimeout(function(){
      db.transaction(
        function(trans){ return callback(new Transaction(trans, db)) },
        function(e){ d.fail(e) },
        function(res){ d.call(res) });
    }, 0);
    return d;
  }
  var Transaction = function(trans, db){
    this.transaction = trans;
    this.database    = db;
    this.version     = db.version;
  }
  Transaction.prototype = {
   // trans.createTable('mytable', {id: 'INTEGER', name: 'TEXT'});
    createTable: function(name, obj){
      var ret = new Deferred();
      var self = this;
      var params = [];
      for(var key in obj){
        if(obj.hasOwnProperty(key)){
          params.push(key + ' ' + obj[key])
        }
      }
      params = params.join(', ');
      this.execute('CREATE TABLE IF NOT EXISTS '+name+' ( ' + params + ' )',
        [],
        function(trans, rs){
          ret.call([(new Transaction(trans, self.database)), rs]);
        });
      return updater(ret);
    },
    // trans.insert('mytable', {id: 1, name: 'Constellation' });
    insert: function(name, obj){
      var ret = new Deferred();
      var self = this;
      var place = [];
      var columns = [];
      var values = [];
      for(var k in obj){
        if(obj.hasOwnProperty(k)){
          columns.push(k);
          place.push(obj[k]);
          values.push('?');
        }
      }
      columns = columns.join(', ');
      values = values.join(', ');
      this.execute('INSERT INTO '+name+' ( '+columns+' ) VALUES ( '+values+' )',
        place,
        function(trans, rs){
          ret.call([(new Transaction(trans, self.database)), rs]);
        });
      return updater(ret);
    },
    selectAll: function(name){
      var ret = new Deferred();
      var self = this;
      this.execute('SELECT * FROM '+name, [],
        function(trans, rs){
          ret.call([(new Transaction(trans, self.database)), rs]);
        });
      return updater(ret);
    },
    select: function(name, obj){
      var ret = new Deferred();
      var self = this;
      var place = [];
      var data = [];
      for(var k in obj){
        if(obj.hasOwnProperty(k)){
          data.push('( '+k+' = ? )');
          place.push(obj[k]);
        }
      }
      data = data.join(' AND ');
      this.execute('SELECT * FROM '+name+' WHERE '+data, place,
        function(trans, rs){
          ret.call([(new Transaction(trans, self.database)), rs]);
        });
      return updater(ret);
    },
    del: function(name, obj){
      var ret = new Deferred();
      var self = this;
      var place = [];
      var data = [];
      for(var k in obj){
        if(obj.hasOwnProperty(k)){
          data.push('( '+k+' = ? )');
          place.push(obj[k]);
        }
      }
      data = data.join(' AND ');
      this.execute('DELETE FROM '+name+' WHERE '+data,
        place,
        function(trans, rs){
          ret.call([(new Transaction(trans, self.database)), rs]);
        });
      return updater(ret);
    },
    delAll: function(name){
      var ret = new Deferred();
      var self = this;
      this.execute('DELETE FROM '+name,
        [],
        function(trans, rs){
          ret.call([(new Transaction(trans, self.database)), rs]);
        });
      return updater(ret);
    },
    // raw
    execute: function(){
      var args = Array.prototype.slice.call(arguments);
      var trans = this.transaction;
      trans.executeSql.apply(trans, args);
    }
  }

})();

実例としては

Deferred.define();
var db = openDatabase('test', '1.0', 'database for test');
var global = {};
alert(1);
next(function(){
  alert(2);
  return SQL(db, function(tr){
    alert(3);
    // trのメソッドの返り値からそのままinsertなどがメソッドチェーンとしてつなげられる
    tr.createTable('config', {key: 'TEXT', value: 'TEXT'})
    .insert('config', {key: '1', value: 'Constellation' })
    .insert('config', {key: '斑', value: 'ニャンコ先生' })
    // 途中からnext(JSDeferredのメソッド)に乗り換え可能
    .next(function(arr){
      var tr = res[0];
      return tr.select('config',{key: '斑'})
        .next(function(arr){
          alert(4);
          var tr = arr[0];
          var result = arr[1];
          for(var i = 0, l = result.rows.length; i < l; i++){
            var row = result.rows.item(i);
            var key = row.key;
            var value = row.value;
            if(key && value) global[key] = value;
          }

          return tr.select('config', {key: '1'})
            .next(function(arr){
              alert(5);
              var tr = arr[0];
              var result = arr[1];
              for(var i = 0, l = result.rows.length; i < l; i++){
                var row = result.rows.item(i);
                var key = row.key;
                var value = row.value;
                if(key && value) global[key] = value;
              }
              });
        })
        .next(function(){
          alert(6);
        });
    });
  });
})
.next(function(res){
  alert(7);
  for(var k in global){
    if(global.hasOwnProperty(k)){
      console.log(k + ' : ' + global[k]);
    }
  }
})
.next(function(){
  alert(8);
})
.error(function(e){
  alert(e.message);
});

補足

なかなかむずい.
未熟なもので, 間違ってるところとかあったらぜひ教えてください. 勉強になってとてもありがたいです.