uhyohyo.net

JavaScript初級者から中級者になろう

十四章第五回 Indexed Database 4

このページの最終更新日:

インデックス

いよいよインデックス(index)を解説します。インデックスとは、日本語に直すと「索引」ということになります。

前回紹介した各種の検索は全てkeyやkeyの範囲を指定するものでした。それは、実はオブジェクトストア内のデータはkeyで整列されて保存されているからです。

レコードがまったく整理されずに適当に並んでいる様子を想像してみましょう。その中から目的のkeyを持つレコードを探しだすのは骨が折れる作業です。一方、keyで整列されていれば見つけるのは簡単です。これは、紙の辞書から目的の言葉を探す様子を想像すれば分かりやすいでしょう。

簡単にいうとインデックスとは、key以外の何かでレコードを整列して保存しておくことを指します。そうすることで、その何かを検索条件にした検索が可能になります。

この「何か」のことを、インデックスが持つkeyといいます。これは本当のkeyとは違うのでややこしいですが、仕方ないですね。

例えば、次のように、id,name,ageという3つのプロパティを持つオブジェクトをレコードとして保存する場合を考えましょう。


{
  id: 1,		//←これがオブジェクトストアにおけるkey
  name: "John Smith",
  age:26
}

コメントで示した通り、オブジェクトストアのキーがin-lineで、keyPathが"id"だとすると、今まで解説したように、このidを用いて検索することができました。

ところが、今度は年齢順に並べたくなったとしましょう。そのときには、このオブジェクトストアに対して、新たにageをkeyPathとするインデックスを作ってそれを使えばいいのです。

今keyPathという用語が出ましたが、実はインデックスは自身のkeyPathを持ちます。keyPathは、オブジェクトストアの設定でin-line keyとするときに使ったものですね。あれと同様です。

また、keyPathが登場することからも分かるように、インデックスは基本的に、レコードがオブジェクトでないと効果を発揮しません。

それではまず、インデックスの作り方から解説します。

インデックスを作る

インデックスというのはオブジェクトストアの初期設定のようなものなので、versionchangeトランザクションの最中しか作れません

インデックスを作るには、IDBObjectStoreのメソッドであるcreateIndexを呼び出します。返り値として、インデックスを表すIDBIndexオブジェクトが得られます。

インデックスも複数存在し得るので、名前を付けます。第一引数はインデックスの名前(文字列)です。第二引数は上で述べた、インデックスのkeyPathです。通常は文字列です。そして第三引数にオプションです。これはcreateObjectStoreの第二引数と同じように、複数のプロパティをもつオブジェクトです。プロパティはuniquemultiEntryの2つでいずれも真偽値、省略された場合デフォルトはfalseです。例えば次のようなオブジェクトになります。


{
  unique:true,
  multiEntry:false,
}

uniqueがtrueのときは、そのインデックスにおいてkeyが重複してはいけなくなります。というのも、レコードのもともとのkeyは重複してはいけないのは当然ですが、それ以外については特に定められていませんでしたね。ですから、インデックス内で他のプロパティをkeyとして扱った場合に、それらが重複することが起こりえます。uniqueがtrueであるインデックスでは、それは許可されません。重複するレコードをオブジェクトストアに追加しようとすると失敗します。

multiEntryは、インデックスを用いた検索時に、keyとして扱われるプロパティが配列だった場合の扱われ方が変わります。falseのときは通常通り、配列は配列として扱われます。前に解説したとおり、keyとしては、文字列、Date、数値のいずれよりも大きいものでしたね。

multiEntryがtrueになると、検索時の配列の挙動が変わります。配列の要素のうち、どれか一つでも条件を満たせば当てはまるようになるのです。つまり、例えば"age"をkeyPathにもつインデックスで検索することを考え、オブジェクトストアには次のオブジェクトがあるとします。keyであるageが配列になっています。


{
  id: 1,
  name: "John Smith",
  age:[0, 10, 20, 30, 40, 50, 60]
}

multiEntryがtrueの場合、この人物は0歳、10歳、20歳、30歳、40歳、50歳、60歳という複数の顔を持ち、その中のどれか一つでも条件に当てはまれば出てくるという変な人になるのです。

インデックスの扱い方に移る前に、対になるメソッドdeleteIndexも紹介しておきます。引数はインデックス名だけで、そのインデックスを削除します。返り値はありません。

インデックスを使用する

さて、いざインデックスを使用して検索してみましょう。まずIDBObjectStoreを用意するところまでは同じです。IDBObjectStoreは、indexというメソッドを持っていて、これを用いてインデックスを取得できます。引数はインデックス名だけで、返り値がIDBIndexです。

まずIDBIndexのプロパティを紹介します。全て書き換えできません。

name
インデックス名です。
objectStore
検索対象のオブジェクトストアです。
keyPath
作るときに指定されたkeyPathです。
multiEntry
オプションそのままです。
unique
これもオプションそのままです。

そして次にIDBIndexのメソッドを紹介します。まず紹介するのはgetです。IDBObjectStoreにもありましたね。

実際これはそれとかなり似たメソッドです。返り値はIDBRequestで、key(またはkey range)を引数に渡すと、結果として当てはまる最初のレコードが得られます。ただし、ここでいうkeyはオブジェクトストアのkeyではなくて、インデックスに定められたほうです。基本的にインデックスの説明でkeyといったら、インデックスの検索で使用されるほうの擬似keyであると考えて構いません。

といった直後に登場するのはgetKeyメソッドです。

これもgetと同様にkeyを引数に取りIDBRequestを返すのですが、結果として得られるものが違います。当てはまったレコードそのものではなく、そのレコードが持つ本来のkey(オブジェクトストアで定められたほうのkey)です。ややこしいですが理解しましょう。

そしてもう一つcountというのもあります。これはIDBObjectStoreのもつcountと動作は全く同じです。ただしkey(またはkey range)が与えられた場合、使われるのはインデックスのほうのkeyです。

そしてここからが本命です。openCursorメソッドです。

といっても動作は実は、前回紹介したIDBObjectStoreのopenCursorと同じです。カーソルの扱い方なども全て同じなのでもう一度解説はしません。何度も繰り返しますが、違うのはkeyが、オブジェクトストアではなくインデックスのkeyが用いられる点だけです。

ただ、ここで意味を帯びるのが、IDBCursorのprimaryKeyというプロパティです。インデックスにおけるカーソルでは、そのkeyプロパティが、オブジェクトストアの本来のkeyではなく、インデックスのkeyPathに対応したkeyになるのはさんざん説明した通りですが、そこで本来のkeyを得たい場合はこのprimaryKeyに入っています。

ちなみにインデックスではない普通のオブジェクトストア上のカーソルの場合でもprimaryKeyは使えますが、ただのkeyプロパティと同じです。

また、前回紹介した"nextunique"や"prevunique"というのは、このインデックスにおけるカーソルの場合に意味を持ってきます。オブジェクトストアのkeyとは違うので、keyが重複する可能性が出てくるからです。詳しい動作は前回を参照して下さい。

ちなみにopenKeyCursorというほとんど同じような動作をするメソッドがあります。

違いは、それで得られたカーソルが、valueプロパティが使えないという点です。下位互換なのであまり使う機会はないかと思います。

以上でインデックスの使用法の話は終わりです。同時に、indexedDBの基本的な使い方を説明し終えたということです。次の例は、政治家をいくつか登録して、特定の政党に所属する人だけ抜き出すサンプルです。(※政党名などは執筆当時のものです。)個々の政治家を表すレコードは以下のようにnameとpartyを持つということにしましょう。


{
  name:"小泉進次郎", //名前
  party:"自民党"     //政党
}

var request = indexedDB.open("test2",1);	//test2というデータベースをバージョン1で開く
//データベースの更新処理
request.addEventListener("upgradeneeded",function(e){
  var db=request.result;	//resultにはIDBDatabaseが入っている

  //fooというオブジェクトストアを作っておく。out-of-line keyでキージェネレータを使用する。
  var store=db.createObjectStore("foo",{
    autoIncrement:true
  });	//返り値はIDBObjectStore
  //partyIndexというインデックスも作っておく。partyプロパティをkeyとして使用する
  store.createIndex("partyIndex","party",{
    unique:false,	//同じ政党の人が複数いてもいいのでfalse
    multiEntry:false,	//複数の政党に所属する人もいないので・・・
  });
  //とりあえず最初にいくつかレコードを追加しておく
  store.add({
    name:"小泉純一郎",
    party:"自民党",
  });
  store.add({
    name:"安倍晋三",
    party:"自民党",
  });
  store.add({
    name:"福田康夫",
    party:"自民党",
  });
  store.add({
    name:"麻生太郎",
    party:"自民党",
  });
  store.add({
    name:"鳩山由紀夫",
    party:"民主党",
  });
  store.add({
    name:"菅直人",
    party:"民主党",
  });
  store.add({
    name:"野田佳彦",
    party:"民主党",
  });
  store.add({
    name:"安倍晋三",
    party:"自民党",
  });
});
//成功したときのイベントハンドラ
request.addEventListener("success",function(e){
  var db=request.result;	//resultにはIDBDatabaseが入っている

  //トランザクションを作る
  var transaction=db.transaction("foo","readonly");
  //オブジェクトストアを得る
  var objectStore=transaction.objectStore("foo");
  //インデックスを得る インデックスはIDBIndex
  var index=objectStore.index("partyIndex");
  //インデックスを用いて検索するカーソルを得る keyを指定して絞る
  //返り値はIDBCursor
  var req=index.openCursor("自民党","next");
  req.addEventListener("success",function(e){
    if(req.result==null){
      //終了
      console.log("終了しました。");
    }else{
      //req.resultにIDBCursorが入っている
      var cursor=req.result;
      console.log(cursor.value.name);
      cursor.advance(1);	//次へ進む
    }
  });

  req.addEventListener("error",function(e){
    console.error(req.error);
  });
});
//失敗したとき
request.addEventListener("error",function(e){
  console.error(request.error);
});

実行すると


小泉純一郎
安倍晋三
福田康夫
麻生太郎
安倍晋三
終了しました。

という結果になるはずです。インデックスを用いて、本来のkeyではなくpartyという別のプロパティで絞り込むことができました。また、"自民党""民主党"に変えると以下の様な結果になることでしょう。


鳩山由紀夫
菅直人
野田佳彦
終了しました。

今までのサンプルではオブジェクトストアを作るときにデータを全部登録してしまいましたが、実際はユーザーの操作に応じて途中でデータを登録することで、実用的なものを作ることができます。

補足

以上で基本は全部説明し終えたので、ここからは補足的な内容をいくつか紹介して終わりにします。

インデックスに配列のkeyPath

実はインデックスのkeyPathには、文字列の他に、複数のkeyPathを並べた配列も許可されています。一方、オブジェクトストアのkeyPathはそれは許可されていません。また、インデックスでも、multiEntryがtrueのときはできません。

keyPathが配列のとき何が起こるかというと、その配列のそれぞれの要素に対応するkeyを配列でまとめたものがそのレコードのkeyとなります。

分かりにくいと思うので具体例で説明すると、例えば次のようなレコードを考えましょう。(情報は2013年3月現在のものです)


{
  name:"麻生太郎",
  party:"自民党",
  age:72
}

もしインデックスのkeyPathが次のような配列だったとします。

["party","age"]

このとき、このインデックスにおけるさっきのレコードのkeyは次の配列になります。

["自民党",72]

つまり、うまく配列の大小関係を用いてやれば複数の条件を同時に指定することが可能かもしれません。これは直感的には、複数の条件で並べておく(今回の場合、まず政党名で並べてその中で年齢で並べる)ことに対応します。そのため、特に範囲指定をしたい場合はプロパティ名の順番が大事になります。例えば、2つのプロパティが目的と一致するレコードを探し出すサンプルを紹介しておきます。


var request = indexedDB.open("test3",1);	//test3というデータベースをバージョン1で開く
//データベースの更新処理
request.addEventListener("upgradeneeded",function(e){
  var db=request.result;	//resultにはIDBDatabaseが入っている

  //fooというオブジェクトストアを作っておく。in-line keyでキージェネレータを使用する。
  var store=db.createObjectStore("foo",{
    keyPath:"id",
    autoIncrement:true
  });	//返り値はIDBObjectStore
  //partyAndAgeというインデックスも作っておく
  store.createIndex("partyAndAge",["party","age"],{
    unique:false,
    multiEntry:false,
  });
  //最初にいくつかレコードを追加しておく
  store.add({
    name:"小泉純一郎",
    party:"自民党",
    age:71,
  });
  store.add({
    name:"安倍晋三",
    party:"自民党",
    age:58,
  });
  store.add({
    name:"福田康夫",
    party:"自民党",
    age:76,
  });
  store.add({
    name:"麻生太郎",
    party:"自民党",
    age:72,
  });
  store.add({
    name:"鳩山由紀夫",
    party:"民主党",
    age:66,
  });
  store.add({
    name:"菅直人",
    party:"民主党",
    age:66,
  });
  store.add({
    name:"野田佳彦",
    party:"民主党",
    age:55,
  });
  store.add({
    name:"安倍晋三",
    party:"自民党",
    age:58,
  });
});
//成功したときのイベントハンドラ
request.addEventListener("success",function(e){
  var db=request.result;	//resultにはIDBDatabaseが入っている

  //トランザクションを作る
  var transaction=db.transaction("foo","readonly");
  //オブジェクトストアを得る
  var objectStore=transaction.objectStore("foo");
  //インデックスを得る
  var index=objectStore.index("partyAndAge");

  //返り値はIDBCursor
  var req=index.openCursor(["自民党",71],"next");	//自民党でしかも71歳の人を探す
  req.addEventListener("success",function(e){
    if(req.result==null){
      //終了
      console.log("終了しました。");
    }else{
      //req.resultにIDBCursorが入っている
      var cursor=req.result;
      console.log(cursor.value.name);
      cursor.advance(1);	//次へ進む
    }
  });

  req.addEventListener("error",function(e){
    console.error(req.error);
  });
});
//失敗したとき
request.addEventListener("error",function(e){
  console.error(request.error);
});

小泉純一郎だけが結果として現れるはずです。

さらに、範囲指定の例も紹介しておきます。今回の場合、政党名を1つに絞ったうえで年齢の範囲を指定することができます。例えば次のサンプルは、Key Rangeを用いて、自民党で70歳以上80歳未満の人を探すサンプルです。上のサンプルで用いたデータベースと同じデータベースや同じインデックスなどを用いるので、上のサンプルに続けて実行してみて下さい。


var request = indexedDB.open("test3",1);	//test3というデータベースをバージョン1で開く
//成功したときのイベントハンドラ
request.addEventListener("success",function(e){
  var db=request.result;	//resultにはIDBDatabaseが入っている

  //トランザクションを作る
  var transaction=db.transaction("foo","readonly");
  //オブジェクトストアを得る
  var objectStore=transaction.objectStore("foo");
  //インデックスを得る
  var index=objectStore.index("partyAndAge");

  //まずKeyRangeを作る
  var range=IDBKeyRange.bound(["自民党",70],["自民党",80],false,true);
  //返り値はIDBCursor
  var req=index.openCursor(range,"next");	//IDBKeyRangeを渡す
  req.addEventListener("success",function(e){
    if(req.result==null){
      //終了
      console.log("終了しました。");
    }else{
      //req.resultにIDBCursorが入っている
      var cursor=req.result;
      console.log(cursor.value.name);
      cursor.advance(1);	//次へ進む
    }
  });

  req.addEventListener("error",function(e){
    console.error(req.error);
  });
});
//失敗したとき
request.addEventListener("error",function(e){
  console.error(request.error);
});

結果は次のようになります。


小泉純一郎
麻生太郎
福田康夫
終了しました。

ここで注目すべきは、年齢が低い順に並び替えられていることですね。インデックスのkeyによりレコードがソートされているのでこうなります。配列の大小の定義からいって、年齢よりも前にある政党によるソートが優先するはずですが、今回は"自民党"だけなので年齢によるソートだけになっています。試しに次のソースで、全員をpartyAndAgeインデックスによって並び替えてみましょう。


  var request = indexedDB.open("test3",1);	//test3というデータベースをバージョン1で開く
//成功したときのイベントハンドラ
request.addEventListener("success",function(e){
  var db=request.result;	//resultにはIDBDatabaseが入っている

  //トランザクションを作る
  var transaction=db.transaction("foo","readonly");
  //オブジェクトストアを得る
  var objectStore=transaction.objectStore("foo");
  //インデックスを得る
  var index=objectStore.index("partyAndAge");

  //返り値はIDBCursor
  var req=index.openCursor(null,"next");	//今回は全員なのでkeyはnull
  req.addEventListener("success",function(e){
    if(req.result==null){
      //終了
      console.log("終了しました。");
    }else{
      //req.resultにIDBCursorが入っている
      var cursor=req.result;
      console.log(cursor.value.name);
      cursor.advance(1);	//次へ進む
    }
  });

  req.addEventListener("error",function(e){
    console.error(req.error);
  });
});
//失敗したとき
request.addEventListener("error",function(e){
  console.error(request.error);
});

結果はこうです。

野田佳彦
鳩山由紀夫
菅直人
安倍晋三
安倍晋三
小泉純一郎
麻生太郎
福田康夫
終了しました。

まず民主党が来て、次に自民党がきていることがわかります。これは配列の0番目のほうが、1番目よりも大小比較に強い影響を与えるからです。文字列比較では"民主党""自民党"より小さいことが分かります。各党の中では年齢順になっています。

このように、範囲指定では使いにくいかもしれませんが、並び替えのためなら配列のkeyPathも使い道があるかもしれません。

トランザクションのイベント

実はトランザクション(IDBTransaction)にもイベントが発生します。

発生するイベントはabort,complete,errorの3つです。

abortとはトランザクションが中断されたとき、completeはトランザクションが正しく終了したとき、errorはエラーが発生したときです。

completeで、トランザクションが正しく終了したというのは、そのトランザクションを用いたリクエストが全て終了したときを指します。具体的には、そもそもプログラムというのは基本的には上から下に進みますが、最後まで進んでしまったら当然終了します(もちろん、その後何かイベントなどが発生して関数が呼び出されたら始まりますが、それもそのうち終わります)。終了してすることが何も無くなった状態を、イベントループに戻ったなどといいます。この状態になって、なおかつもう処理すべきリクエストが残っていないときが、トランザクションが終了したときです。

簡単にいえば、処理が全部終わったときと考えて差し支えありません。これを、さっきのサンプルに追加して試してみましょう。


var request = indexedDB.open("test3",1);	//test3というデータベースをバージョン1で開く
//成功したときのイベントハンドラ
request.addEventListener("success",function(e){
  var db=request.result;	//resultにはIDBDatabaseが入っている

  //トランザクションを作る
  var transaction=db.transaction("foo","readonly");
  //トランザクションに対してイベントを登録する
  transaction.addEventListener("complete",function(e){
    console.log("トランザクションが終了しました。");
  });
  //オブジェクトストアを得る
  var objectStore=transaction.objectStore("foo");
  //インデックスを得る
  var index=objectStore.index("partyAndAge");

  //返り値はIDBCursor
  var req=index.openCursor(null,"next");
  req.addEventListener("success",function(e){
    if(req.result==null){
      //終了
      console.log("終了しました。");
    }else{
      //req.resultにIDBCursorが入っている
      var cursor=req.result;
      console.log(cursor.value.name);
      cursor.advance(1);	//次へ進む
    }
  });

  req.addEventListener("error",function(e){
    console.error(req.error);
  });
});
//失敗したとき
request.addEventListener("error",function(e){
  console.error(request.error);
});

結果は次のようになり、確かに全部終わってからcompleteイベントが発生していることが分かります。


野田佳彦
鳩山由紀夫
菅直人
安倍晋三
安倍晋三
小泉純一郎
麻生太郎
福田康夫
終了しました。
トランザクションが終了しました。

このcompleteイベントは、処理が全部終了してから次に進みたいときなど、使いどころがあるかもしれません。今回の場合はreq.resultがnullだったときに全部終了したとはっきりしていますが、複数のカーソルを同時に動かしたりした場合など、どちらが先に終わるのかわからないので面倒です。その場合はcompleteイベントが役に立ちます。

cmpメソッド

実は十四章第二回で、IDBFactoryは3つのメソッドを持つといいましたが、openとdeleteDatabaseの2つしか紹介していません。3つ目を紹介します。

3つ目はcmpメソッドです。2つのkeyを引数にとって、大小を比較して結果を数値で返してくれます。

同じなら0、第一引数が第二引数より大きいなら1,第一引数が第二引数より小さいなら-1です。いくつか試してみましょう。


console.log(indexedDB.cmp(5,0));	// 1(数値どうしの比較)
console.log(indexedDB.cmp("文字列",Infinity));	// 1(文字列は数値より大きい)
console.log(indexedDB.cmp(new Date(),"string"));	// -1(文字列はDateより大きい)
console.log(indexedDB.cmp([5],[4,8,12,16,20]));	// 1(配列どうしの比較)

以上で説明は全て終了です。今回のポイントは、オブジェクトストアのkey以外のもので検索を行いたい場合はインデックスを用いるということです。インデックスはオブジェクトストアと同様、upgradeneededイベントで作る必要があります。

Indexed Databaseはデータベースとしては非常にシンプルなAPIを持ちますが、検索などの機能が他の本格的データベースに比べて貧弱な気がします。検索条件として範囲指定しかできませんし、複数の条件を同時に指定するのは難しいです。これは、一般のデータベースソフトウェアに比べてインデックスの存在がとても明示的であることに由来しています。それらと同等の機能が欲しければインデックスを駆使して自分で実装する必要があるわけです。

実際には、スケールにもよりますが、とりあえずKey Rangeで一次的に絞り込んで、その後は検索結果に対してif文などで絞り込んでいくということもあるでしょう。

それでも、localStorageよりははるかに強力な機能を持っています。ぜひ使いこなしましょう。