IndexedDB 複合キー & 範囲検索 実装パターンとハマりどころ
はじめに
IndexedDB はブラウザ内で使える非同期型 NoSQL データベースです。
大量データの高速検索が可能ですが、SQL のような WHERE
構文はなく、検索は インデックス と カーソル を組み合わせます。
この記事では、実際の開発で遭遇した 複合キー検索・範囲検索・ページング の実装例と、
lowerBound / upperBound でハマった原因 をまとめます。
今回の要件
-
単一キー検索
-
複合キー検索
-
範囲検索(開始位置指定)
-
offset & limit 対応(ページング)
-
昇順/降順両対応
-
フィルタ条件付き検索(オプション)
基本構造
IndexedDB の検索は以下の流れになります。
graph TD;
A[openDatabase()] --> B[transaction.objectStore()]
B --> C[store.index(key)]
C --> D[index.openCursor(range, direction)]
D --> E[カーソルループで結果配列に追加]
1. 単一キー検索 (IDBKeyRange.only
)
const range = IDBKeyRange.only(id);
const request = index.openCursor(range, 'next');
これでキーが完全一致するレコードだけ取得できます。
複合キーの場合も配列で渡すだけ。
const range = IDBKeyRange.only([channelID, parentID]);
2. 複合キー検索のメリット
例えば channelID
と parentID
両方で絞りたい場合、
filterKey / filterValue のように二重条件チェックを JS 側でやると遅くなります。
複合インデックス を作っておけば、最初からカーソルが該当範囲だけを走査するため高速です。
objectStore.createIndex('channelID_parentID', ['channelID', 'parentID']);
3. 範囲検索 (lowerBound
/ upperBound
)
IndexedDB では「開始位置」を指定してページング的に取得できます。
let range;
if (sortOrder === 'asc') {
range = IDBKeyRange.lowerBound(startValue, false); // inclusive = true にすると自身も含む
} else {
range = IDBKeyRange.upperBound(startValue, false);
}
これで startValue
以降(または以前)のデータを取得できます。
4. offset & limit
ページング用の offset は JS 側でスキップ制御します。
let skipped = 0;
request.onsuccess = (event) => {
const cursor = event.target.result;
if (!cursor) return resolve(results);
if (skipped < offset) {
skipped++;
return cursor.continue();
}
results.push(cursor.value);
if (results.length >= limit) return resolve(results);
cursor.continue();
};
5. lowerBound / upperBound でハマった話 🤔
症状
IDBKeyRange.lowerBound(value)
を昇順で使った場合に、
開始値自身(value)が含まれなかったり、逆にスキップされたりする 問題が発生。
さらに upperBound
を降順で使った場合も同様に、
意図した開始位置でカーソルが止まらないことがありました。
原因
IndexedDB の範囲指定は インデックスキーに対してのみ作用 します。
そのため、次のようなケースで意図通りに動きませんでした。
-
検索対象が filterIndex(複合キー)で、範囲条件は別のフィールドにした場合
-
lowerBound
/upperBound
の第2引数(inclusive)設定が意図と逆だった場合 -
開始位置の値がインデックスキーと一致しない(同じ値が複数レコードにある)場合
つまり、「filter で対象を絞った後に別のフィールドで範囲指定」はカーソルのレベルではできないんです。
カーソルはあくまで「開いたインデックスのソート順」に沿って走査するので、別フィールドの値で開始位置を決めても飛びません。
対策
この問題を避けるために、今回は以下の方針を採用しました。
-
開始位置条件(value)は filterIndex と同じキーで作る
-
lowerBound / upperBound を使わず、カーソルループ内で比較してスキップ制御
-
複合キーを適切に設計して、最初からソートと範囲指定を両立させる
結果、queryIndexByValue
では lowerBound
/ upperBound
を削除し、
カーソル開始後に比較して continue させる方法に変更しました。
6. 実装例
複合キー完全一致 + ページング
async function getIDBs(table, key, id, limit = 5, offset = 0, sortOrder = 'desc') {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const store = db.transaction([table], 'readonly').objectStore(table);
if (!store.indexNames.contains(key)) return resolve([]);
const index = store.index(key);
const request = index.openCursor(IDBKeyRange.only(id), sortOrder === 'asc' ? 'next' : 'prev');
const results = [];
let skipped = 0;
request.onsuccess = (e) => {
const cursor = e.target.result;
if (!cursor) return resolve(results);
if (skipped < offset) {
skipped++;
return cursor.continue();
}
results.push(cursor.value);
if (results.length >= limit) return resolve(results);
cursor.continue();
};
request.onerror = (e) => reject(e.target.error);
});
}
範囲指定 + フィルタ付き
async function queryIndexByValue({
table,
indexKey,
value,
limit = 10000000,
offset = 0,
direction = 'desc',
filter = null
}) {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const store = db.transaction([table], 'readonly').objectStore(table);
if (!store.indexNames.contains(indexKey)) return resolve([]);
const index = store.index(indexKey);
const results = [];
let skipped = 0;
const request = index.openCursor(null, direction === 'asc' ? 'next' : 'prev');
request.onsuccess = (event) => {
const cursor = event.target.result;
if (!cursor) return resolve(results);
if (filter) {
const [filterKey, filterValue] = filter;
if (Array.isArray(filterValue)) {
if (JSON.stringify(cursor.value[filterKey]) !== JSON.stringify(filterValue)) {
return cursor.continue();
}
} else if (cursor.value[filterKey] !== filterValue) {
return cursor.continue();
}
}
// 範囲条件はカーソル内でチェック
const compareField = indexKey.replace('Index', '');
const compareVal = cursor.value[compareField];
if (direction === 'asc' && compareVal < value) return cursor.continue();
if (direction === 'desc' && compareVal > value) return cursor.continue();
if (skipped < offset) {
skipped++;
return cursor.continue();
}
results.push(cursor.value);
if (results.length >= limit) return resolve(results);
cursor.continue();
};
request.onerror = (e) => reject(e.target.error);
});
}
まとめ
-
IndexedDB の範囲検索は インデックスキーにのみ有効
-
別フィールドでの範囲条件はカーソル内で制御
-
複合キーをうまく設計すると filterKey / filterValue は不要になる
-
lowerBound / upperBound は便利だが、設計を誤ると「開始値が含まれない」などのバグになる
-
ページングは offset と limit をカーソルループで管理
この内容を押さえておけば、IndexedDB でも SQL 並みの柔軟な検索が実装できます。
次は、昇順⇔降順切替で同じ結果を保証するアルゴリズム もまとめるとさらに強化できますね。
登録日:
更新日: