🏠 ホーム
フロントエンド
PHP
Go言語
プログラミングの理解
プログラマーへの道
Google API

IndexedDB 複合キー & 範囲検索 実装パターンとハマりどころ

  プログラミング >     フロントエンド >  

はじめに

IndexedDB はブラウザ内で使える非同期型 NoSQL データベースです。
大量データの高速検索が可能ですが、SQL のような WHERE 構文はなく、検索は インデックスカーソル を組み合わせます。

この記事では、実際の開発で遭遇した 複合キー検索・範囲検索・ページング の実装例と、
lowerBound / upperBound でハマった原因 をまとめます。


今回の要件

  1. 単一キー検索

  2. 複合キー検索

  3. 範囲検索(開始位置指定)

  4. offset & limit 対応(ページング)

  5. 昇順/降順両対応

  6. フィルタ条件付き検索(オプション)


基本構造

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. 複合キー検索のメリット

例えば channelIDparentID 両方で絞りたい場合、
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 の範囲指定は インデックスキーに対してのみ作用 します。
そのため、次のようなケースで意図通りに動きませんでした。

つまり、「filter で対象を絞った後に別のフィールドで範囲指定」はカーソルのレベルではできないんです。
カーソルはあくまで「開いたインデックスのソート順」に沿って走査するので、別フィールドの値で開始位置を決めても飛びません。


対策

この問題を避けるために、今回は以下の方針を採用しました。

  1. 開始位置条件(value)は filterIndex と同じキーで作る

  2. lowerBound / upperBound を使わず、カーソルループ内で比較してスキップ制御

  3. 複合キーを適切に設計して、最初からソートと範囲指定を両立させる

結果、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 でも SQL 並みの柔軟な検索が実装できます。
次は、昇順⇔降順切替で同じ結果を保証するアルゴリズム もまとめるとさらに強化できますね。


 

登録日:

更新日:

by

コメント         tweetでコメント