noxi雑記

.NET、Angularまわりの小ネタブログ

Dexie.jsとTypeScriptでIndexedDBを操作する

Web技術を使用してリッチクライアントを作成するにはクライアント側でデータを保持することが時に求められます。データの保持はHTML5APIとしてkey-valueストアのWeb Storage、NoSQLのIndexedDB、2種類が存在します。今回はIndexedDBをTypeScriptで操作してみます。

Dexie.jsとTypeScriptでIndexedDBを操作する

Dexie.jsとは

Dexie.jsはIndexedDBの操作をラップするライブラリです。IndexedDBはとても強力ですが、全ての操作が非同期のため、直接APIを使用するとコールバック地獄に陥り辛い思いをします。また定義したIndexに対する検索を行うことができますが使い方が少し複雑です。 Dexei.jsはIndexedDBの操作をPromiseでラップしてコールバック地獄を解消し、また直感的にデータの出し入れを行うことができます。

dexie.org

dexie.org

環境

  • TypeScript 2.6.2
  • Dexie.js 2.0.2

Dexieのインストール

Dexieを使用するには、まずnpmからインストールします。

npm install --save dexie

データベースを作成する

Dexie.jsを使用してデータベースを作成するには2つ方法があります。1つはDexieのサンプルページにあるDexieを継承したデータベースのクラスを作成すること、もう1つはDexieをインスタンス化して操作することです。

データベース作成(クラスとして)

まずはデータベースクラスを作成してみます。データベースクラスはDexieを継承し、コンストラクタ内にデータベース名とスキーマを指定します。

import Dexie from 'dexie';

/**
 * IndexedDBに保存するオブジェクト
 */
export interface SampleEntity {
  id?: number;
  name?: string | null;
  age?: number | null;
}

/**
 * IndexedDBをラップするDexieクラス
 */
export class SampleDatabase extends Dexie {

  // テーブルをプロパティとして定義
  // ジェネリックの1つ目はストアするオブジェクト、2つ目はキーの型
  table_1: Dexie.Table<SampleEntity, number>;
  table_2: Dexie.Table<SampleEntity, number>;

  constructor() {
    super('sample-database'); // データベース名をsuperのコンストラクタに渡す

    // テーブルとインデックスを定義する
    this.version(1).stores({
      table_1: 'id',
      table_2: 'id, name'
    });
  }

}

Dexie.jsは table_1: 'id' のように簡単にIndexedDBのテーブルを生成することができます。この文字列はカンマ区切りで、1つ目がプライマリーキーのプロパティ名、2つめ以降がインデックスのプロパティ名となります。 table_1: 'id' は「テーブル名 table_1 をプライマリーキー id で作成する」、 table_2: 'id, name' は「テーブル名 table_2 をプライマリーキー id で作成し、 name プロパティでインデックスを生成する」ことを意味します。

後述しますが、Dexie.jsではテーブルの操作を Dexie.Table 経由で行います。このSampleDatabaseクラスでは table_1 table_2 プロパティが使用されます。

データベース作成(Dexieインスタンス

次にDexieをインスタンス化してデータベースを作成します。これは普通のJavaScript操作と何ら変わりはありません。

const db: Dexie = new Dexie('sample-database');
db.version(1).stores({
  table_1: 'id',
  table_2: 'id, name'
});
const table1 = db.table('table_1') as Dexie.Table<SampleEntity, number>;

とてもシンプルですが、1つ欠点があります。先にクラスで定義したときとはことなり、事前に型定義を行ったプロパティが存在しないため、テーブルアクセス時に毎回キャストが発生することです。そこで解決策として、Dexieの型を別に定義し、インターフェースを通じて操作します。

/**
 * Dexieのプロパティ名と型を持つ型を作成
 */
export type DexieDatabase = {[P in keyof Dexie]: Dexie[P]};

/**
 * SampleDatabaseのテーブルプロパティだけ作成
 */
export interface SampleDatabase extends DexieDatabase {
  table_1: Dexie.Table<SampleEntity, number>;
  table_2: Dexie.Table<SampleEntity, number>;
}


const db = new Dexie('sample-database') as SampleDatabase;
db.version(1).stores({
  table_1: 'id',
  table_2: 'id, name'
});
db.table_1;

結局は一度キャストが必要になるのですが、テーブル単位では無くデータベース単位でのキャストになるため個人的にはこちらの方がお勧めです。

Dexie.jsのスキーマ定義

IndexedDBにはプライマリーキーのAutoIncrement機能やユニークインデックスの機能があり、Dexie.jsはこれらをサポートしています。

記号 意味
++ AutoIncrementプライマリーキー。
& ユニークインデックス。
* マルチエントリーインデックス(配列の要素をインデックスにする)。 MultiEntry Index
[A+B] 複合インデックス。 Compound Index
{
  friends: '++id,name,shoeSize', // AutoIncrementなプライマリーキー
  pets: 'id, name, kind',        // AutoIncrementでないプライマリーキー
  cars: '++, name',              // AutoIncrementなプライマリーキーだがオブジェクト外。
  enemies: ',name,*weaknesses',  // AutoIncrementでないプライマリーキーかつオブジェクト外。
                                 // 'weaknesses' は配列の要素をインデックスにする。
  users: 'meta.ssn, addr.city',  // ネストされたオブジェクトのプロパティ指定。 
  people: '[name+ssn], &ssn'     // 複合プライマリーキーかつ 'ssn' はユニークなインデックス。
}

オブジェクトを保存する

Dexie.jsでオブジェクトを保存するには add または bulkAdd を使用します。前者が1つのオブジェクトだけ、後者が複数のオブジェクトを一気に保存するのに使用します。

const db = new Dexie('sample-database') as SampleDatabase;
db.version(1).stores({
  table_1: 'id, name'
});
await db.table_1.add({ id: 1, name: 'hoge', age: 10 });
await db.table_1.bulkAdd([
  { id: 2, name: 'hoge', age: 11 },
  { id: 3, name: 'fuga', age: 12 },
  { id: 4, name: 'hoge', age: 13 },
  { id: 5, name: null, age: 14 },
  { id: 6, name: null, age: 15 },
]);

このコードを実行するとIndexedDBに6つのオブジェクトが保存されます。ちなみにIndexedDBのデータベースやテーブル、保存されているオブジェクトはChromeであれば開発者ツールのApplicationタブから確認することができます。

IndexedDBに保存されたデータ

オブジェクトを更新する

Dexie.jsでオブジェクトを更新するには put または bulkPut を使用します。このメソッドはキーが同一のオブジェクトが存在すれば更新、存在していなければ追加を行います。

const db = new Dexie('sample-database') as SampleDatabase;
db.version(1).stores({
  table_1: 'id, name'
});
await db.table_1.put({ id: 1, name: 'hoge', age: 10 });
await db.table_1.bulkPut([
  { id: 2, name: 'hoge', age: 11 },
  { id: 3, name: 'fuga', age: 12 },
  { id: 4, name: 'hoge', age: 13 },
  { id: 5, name: null, age: 14 },
  { id: 6, name: null, age: 15 },
]);

IndexedDBに保存されたデータ

オブジェクトを削除する

Dexie.jsでオブジェクトを削除するには delete またはbulkDelete を使用します。引数にはオブジェクトでは無くプライマリーキーの値を渡します。

const db = new Dexie('sample-database') as SampleDatabase;
db.version(1).stores({
  table_1: 'id, name'
});
await db.table_1.delete(1);
await db.table_1.bulkDelete([2, 3, 4, 5, 6]);

オブジェクトを全て取得する

Dexie.jsでオブジェクトを全件取得するには toArray を使用します。

const db = new Dexie('sample-database') as SampleDatabase;
db.version(1).stores({
  table_1: 'id, name'
});
await db.table_1.put({ id: 1, name: 'hoge', age: 10 });
await db.table_1.bulkPut([
  { id: 2, name: 'hoge', age: 11 },
  { id: 3, name: 'fuga', age: 12 },
  { id: 4, name: 'hoge', age: 13 },
  { id: 5, name: null, age: 14 },
  { id: 6, name: null, age: 15 },
]);

const data = await db.table_1.toArray();
console.log('data: ', data);

IndexedDBから取得したデータ

オブジェクトを検索する(プライマリーキー)

Dexie.jsでプライマリーキーを使用して特定のオブジェクトを取得するには get を使用します。

const db = new Dexie('sample-database') as SampleDatabase;
db.version(1).stores({
  table_1: 'id, name'
});
await db.table_1.put({ id: 1, name: 'hoge', age: 10 });
await db.table_1.bulkPut([
  { id: 2, name: 'hoge', age: 11 },
  { id: 3, name: 'fuga', age: 12 },
  { id: 4, name: 'hoge', age: 13 },
  { id: 5, name: null, age: 14 },
  { id: 6, name: null, age: 15 },
]);

const data = await db.table_1.get(1);
console.log('data: ', data);

IndexedDBから取得したデータ

オブジェクトを検索する(インデックス)

Dexie.jsで特定のインデックスを使用してオブジェクトを検索するには where を使用します。

const db = new Dexie('sample-database') as SampleDatabase;
db.version(1).stores({
  table_1: 'id, name, age, [name+age]'
});
await db.table_1.put({ id: 1, name: 'hoge', age: 10 });
await db.table_1.bulkPut([
  { id: 2, name: 'hoge', age: 11 },
  { id: 3, name: 'fuga', age: 12 },
  { id: 4, name: 'hoge', age: 13 },
  { id: 5, name: null, age: 14 },
  { id: 6, name: null, age: 15 },
]);

console.log('nameがhogeに等しい:\n',
  await db.table_1.where('name').equals('hoge').toArray());

console.log('nameがhogeに等しいまたはageが11に等しい:\n',
  await db.table_1
    .where('name').equals('hoge')
    .or('age').equals(15)
    .toArray());

console.log('ageが12より大きい;\n',
  await db.table_1.where('age').above(12).toArray());

IndexedDBから取得したデータ

Whereで使用出来るAPI

テーブルのWhereの後に使用出来るものの一覧です( WhereClause )。微妙な使いづらさは元々のIndexedDB自体があまり使い勝手が良くないことに由来します。

API 説明
above 数値比較で、指定した値よりも大きい。
aboveOrEqual 数値比較で、指定した値と同じか大きい。
anyOf 配列を渡し、配列の要素のいずれかと等しい。
anyOfIgnoreCase 文字列の配列を渡し、配列の要素のいずれかと大文字小文字を区別せず等しい。
below 数値比較で、指定した値よりも小さい。
belowOrEqual 数値比較で、指定した値と同じか小さい。
between 数値比較で、指定範囲。
equals 指定の値と等しい。
equalsIgnoreCase 文字列比較で、指定の値と大文字小文字を区別せず等しい。
inAnyRange betweenを複数条件設定する。
noneOf 配列を渡し、配列要素のいずれとも等しくない。
notEqual 指定の値と等しくない。
startsWith 文字列が指定の文字列を先頭に含む。
startsWithIgnoreCase 文字列が指定の文字列を大文字小文字区別せず先頭に含む。
startsWithAnyOf 配列を渡し、文字列が配列の要素のいずれかを先頭に含む。
startsWithAnyOfIgnoreCase 配列を渡し、文字列が配列の要素のいずれかを大文字小文字区別せず先頭に含む。