前回からのつづき

前回、IndexedDBの基本動作の確認について説明を行ってきました。今回は、その続きとなります。

今回は、マスターメンテナンス風テストプログラム(cursor版)を中心に説明したいと思います。


getAll版とcursor版の違い

「マスターメンテナンス風テストプログラム(getAll版)」と「マスターメンテナンス風テストプログラム(cursor版)」の違いは、データベースの読み込みに、getAll()を使用しているか、cursorを使用しているかの違いです。

👉 基本動作は全く同じものとなります。

コードは、javascript本体:script.jsの全データ取得部分のみcursor処理に合わせて変更されています。

                          :
                          :
  // カスタムイベント発生時、IndexedDBから全データ取得し、テーブルを再表示
  document.addEventListener('reloadTable', (e) => {
    const tx = db.transaction(e.detail.storename, 'readonly');
    const store = tx.objectStore(e.detail.storename);

    tableObj.setHeader();

    lastId = 0;
    totalCount = 0;
    const results = []

    const request = store.openCursor();
    request.onsuccess = (event) => {
      const cursor = event.target.result;
      if (!cursor) return

      results.push(cursor.value)
      tableObj.setData(cursor.value);
      lastId = Number(cursor.value.id);
      totalCount = results.length;

      if (results.length < MAX_COUNT) {
          cursor.continue();
      }
    }
  });
                          :
                          :


マスターメンテナンス風テストプログラム(cursor版)による基本動作の確認

下記のボタンを押下することで、テストプログラムの起動が可能です。

👉 本記事のテストプログラムを動かすことで、「IndexedDBの動き」が体感できます。


テストプログラムの概要(かんたん説明)

このプログラムが保存するデータは、住所録のオブジェクトです。

例)

{
  id: 1,
  name: "山田こん太郎",
  address: "東京都品川区羽田が丘2-10-1",
  tel: "090-1234-5678",
  email: "yamada_kontaro@konkitsune.com"
}

表示画面には、大きく分けて3つのエリアが存在します。

  • テーブルエリア
  • 検索条件エリア
  • 入力欄エリア

テーブルエリア

IndexedDBに格納されているデータ全てが一覧で表示されます。(最大5件) ただし、表示されるのは、(id, name, addressのみ)

検索条件エリア

IDやnameによりIndexedDBに格納されているデータの検索が可能です。検索されたデータは、入力欄に表示され、変更後の上書きが可能です。また、新規データの格納も可能です。

入力欄エリア

データである住所録オブジェクトの各項目を表示したり、書き換え、新規入力を行うものです。

構造はいたってシンプルです。

注)IDはIndexedDBのkeyとして使用します。本プログラムでは、半角数字のみに制限。INDEXは名前のみに付加。

操作手順

手順①:本頁の「テストプログラム起動」を押す

テストプログラム起動 👉 起動時に、保存されたデータがある場合、テーブルエリアに表示されます。

手順②:データの保存

検索条件エリアの「新規」ボタンを押下し、入力欄エリアの名前、住所、TEL、MAILに文字を入力 入力後、「書込」ボタン押下でIndexedDBに保存。 👉 「新規」ボタンを押下後、入力欄が入力可能になります。入力すると「書込」ボタンが押せるようになります。 👉 新規入力を取りやめたい時は、「解除」ボタン押下で、中止可能です。

手順③:ID指定による読込

検索条件エリアのIDに読込したいデータのIDを指定し、「読込」ボタンを押下 👉 入力すると「読込」ボタンが押せるようになります。

手順④:名前による検索

検索条件エリアの名前に検索したいデータの名前を指定し、「検索」ボタンを押下 👉 入力すると「検索」ボタンが押せるようになります。

手順⑤:データの削除

検索条件エリアのIDに削除したいデータのIDを指定し、「削除」ボタンを押下 👉 入力すると「削除」ボタンが押せるようになります。 👉 「ID指定による読込」や「名前による検索」後もIDに値が入るので、削除可能です。

手順⑥:全データの削除

画面最下部の「全削除」ボタンを押下 👉 IndexedDBに保存されている全てのデータが削除されます。


動作確認を実施する

このテストで確認したいこと

このテストでは、次のことを確認していきたいと思います:

  • データがブラウザに保存される仕組み
  • ページをリロードしてもデータが残ること
  • Keyによるデータの読込の動き
  • Indexによるデータの読込の動き
  • 上書き保存の動き
  • 削除・全削除の違い

実際の操作手順(ここが重要)

  1. 手順:「新規」->「保存」で、データを2件以上、保存する。

👉 IndexedDBのオープン、データ保存、読込を確認します

  • indexedDB.open()
  • IDBOpenDBRequest.onupgradeneeded
  • IDBOpenDBRequest.onsuccess
  • IDBDatabase.createObjectStore()
  • IDBDatabase.transaction()
  • IDBTransaction.objectStore()
  • IDBObjectStore.createIndex()
  • IDBObjectStore.openCursor()
  • IDBObjectStore.add()
  • IDBCursor.continue()
  1. 手順:F5や再読み込みを実行した後、テストプログラムを再起動する。

保存したデータが消えずに残っていることを確認します。

  1. 手順:IDによる読込を行う。 指定されたIDのデータが入力欄に表示されることを確認します。

👉 IDBObjectStore.get()の動作を確認します

  1. 手順:名前による検索を行う。 指定された名前のデータが入力欄に表示されることを確認します。

👉 IndexedDBのindex動作を確認します

  • IDBObjectStore.index()
  • IDBIndex.get()
  1. 手順:入力欄の住所を書き換え、「保存」を押す

書き換えた住所で上書きされることを確認します。 👉 IDBObjectStore.put()の上書き動作を確認します

  1. 手順:「削除」を押す

対象のデータだけ削除されることを確認します。 👉 IDBObjectStore.delete()の動作を確認します

  1. 手順:「全削除」を押す

すべてのデータが削除されることを確認します。 👉 IDBObjectStore.clear()の動作を確認します

テスト結果まとめ

  • IndexedDBのアクセスは、オブジェクトのイベントドリブンが基本
  • ページをリロードしてもデータは残る
  • 同じキーは上書きされる
  • 削除しない限りデータは残り続ける
  • IDBOpenDBRequest.onupgradeneededは初回か、バージョンアップのopen時だけ発生 (IDBOpenDBRequest.onupgradeneeded → IDBOpenDBRequest.onsuccessの順で発生)
  • 下記のAPIは、IDBOpenDBRequest.onupgradeneededイベント内でのみ実行可能
    • IDBDatabase.createObjectStore()
    • IDBDatabase.deleteObjectStore()
    • IDBObjectStore.createIndex()
    • IDBObjectStore.deleteIndex()
  • Index作成で、unique指定した場合、書き込み時にduplicateがあるとエラーになる
  • IDBObjectStore.get(),IDBIndex.get()は対象データがなくてもエラーにならない
  • IDBObjectStore.delete()やIDBObjectStore.clear()は対象データがなくてもエラーにならない
  • Edgeでは、ファイル指定(file:///C:/Users/~/index.html)でも動作
  • 開発者ツールのコンソールで保存データが直接確認可能

テストプログラムのコード

下記に、本テストで使用したプログラムのコードを記します。

1. HTML(index.html)

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Session Storage Test</title>
  <link rel="stylesheet" href="./style.css">
  <script type="text/javascript" src="./script.js"></script>
  <script type="text/javascript" src="./table_class.js"></script>
</head>
<body>
  <div id="table-area"></div>
  <p>
    <fieldset>
      <legend>[ 検索条件 ]</legend>
      <div>
        <label for="id">ID:</label>
        <input type="text" id="id" disabled>
        <button id="new" disabled>新規</button>
        <button id="get" disabled>読込</button>
        <button id="put" disabled>書込</button>
        <button id="del" disabled>削除</button>
        <button id="rel" disabled>解除</button>
      </div>
      <div>
        <label for="s-name">名前:</label>
        <input type="text" id="s-name">
        <button id="sch" disabled>検索</button>
      </div>
    </fieldset>
  </p>
  <p>
    <div id="input-area">
      <div>
        <label for="name">名前:</label>
        <input type="text" id="name" disabled>
      </div>
      <div>
        <label for="address">住所:</label>
        <input type="text" id="address" disabled>
      </div>
      <div>
        <label for="tel">電話:</label>
        <input type="text" id="tel" disabled>
      </div>
      <div>
        <label for="email">Mail:</label>
        <input type="text" id="email" disabled>
      </div>
    <div>
  </p>
  <p>
    <div>
      <button id="all-del">全削除</button>
    </div>
  </p>
  <p>
    <div id="popup-status"></div>
  </p>
</body>
</html>

2. CSS(style.css)

body {
  padding: 1rem;
  background-color: rgb(253, 191, 76);
  text-align: center;
  overflow: hidden;
}
#table-area {
  height: 10rem;
  background-color: white;
  overflow-y: auto;
}
#input-area {
  display: block;
  justify-content: left;
  align-items: left; 
}
table {
  border-collapse: separate;
  border-spacing: 0;
  border: 1px solid;
}
th {
  position: sticky;
  top: 0;
  z-index: 1;
  border: 1px solid;
  background-color: skyblue;
  height: 2rem; 
}
td {
  border: 1px solid;
  background-color: white;
  height: 2rem; 
}
.id {
  width: 20rem;
}
.name {
  width: 50rem;
}
.address {
  width: 100rem;
}
input {
  width: 30rem;
}
#id {
  width: 15.5rem;
}
#s-name {
  width: 27rem;
}

#popup-status {
    font-size: 1.25rem;
    font-weight: bold;
    color: red;
    margin-bottom: 1rem;
}

3. JavaScript(本体:script.js)

//
// IndexedDBの動作確認用JavaScriptコード
//
window.addEventListener("load", function() {

  // 表示テーブル作成
  const tableObj = new tableClass("table-area", [
    { name: "id", class: "id", content: "ID" },
    { name: "name", class: "name", content: "名前" },
    { name: "address", class: "address", content: "住所" }
  ]);

  // 画面要素取得
  const input_id = document.getElementById("id");
  const input_s_name = document.getElementById("s-name");
  const input_name = document.getElementById("name");
  const input_address = document.getElementById("address");
  const input_tel = document.getElementById("tel");
  const input_email = document.getElementById("email");
  const btn_new = document.getElementById("new");
  const btn_get = document.getElementById("get");
  const btn_put = document.getElementById("put");
  const btn_del = document.getElementById("del");
  const btn_rel = document.getElementById("rel");
  const btn_sch = document.getElementById("sch");
  const btn_all_del = document.getElementById("all-del");
  const status = document.getElementById("popup-status");

  // 固定値定義
  const MAX_COUNT = 5;

  // 変数定義
  let db = null; // IndexedDBのIDBDatabase(DB接続)オブジェクト
  let lastId = 0; // 最新のKey値

  // ID入力欄の変更を監視して、入力があれば新規ボタンを有効化
  input_id.addEventListener('keyup', function(event) {
    if (this.value) {
      if (!Number(input_id.value)) {
        status.textContent = "IDには、半角数字を入力してください。";
        input_id.focus();
        return;
      }
      input_s_name.value = "";
      input_name.value = "";
      input_address.value = "";
      input_tel.value = "";
      input_email.value = "";
      input_name.disabled = true;
      input_address.disabled = true;
      input_tel.disabled = true;
      input_email.disabled = true;
      btn_new.disabled = true;
      btn_get.disabled = false;
      btn_put.disabled = true;
      btn_del.disabled = false;
      btn_rel.disabled = true;
      btn_sch.disabled = true;
    } else {
      input_s_name.value = "";
      input_name.value = "";
      input_address.value = "";
      input_tel.value = "";
      input_email.value = "";
      btn_new.disabled = false;
      btn_get.disabled = true;
      btn_put.disabled = true;
      btn_del.disabled = true;
      btn_rel.disabled = true;
      btn_sch.disabled = true;
    }
    status.textContent = "";
  });

  // 検索用MAIL入力欄の変更を監視して、入力があれば検索ボタンを有効化
  input_s_name.addEventListener('keyup', function(event) {
    if (this.value) {
      input_id.value = "";
      input_name.value = "";
      input_address.value = "";
      input_tel.value = "";
      input_email.value = "";
      input_name.disabled = true;
      input_address.disabled = true;
      input_tel.disabled = true;
      input_email.disabled = true;
      btn_new.disabled = true;
      btn_get.disabled = true;
      btn_put.disabled = true;
      btn_del.disabled = true;
      btn_rel.disabled = true;
      btn_sch.disabled = false;
    } else {
      input_id.value = "";
      input_name.value = "";
      input_address.value = "";
      input_tel.value = "";
      input_email.value = "";
      btn_new.disabled = false;
      btn_get.disabled = true;
      btn_put.disabled = true;
      btn_del.disabled = true;
      btn_rel.disabled = true;
      btn_sch.disabled = true;
    }
    status.textContent = "";
  });

  // ID以外の入力欄の変更を監視して、変更があれば、書込ボタンを有効化
  [input_name, input_address, input_tel, input_email].forEach(function (input) {
    input.addEventListener('change', function(event) {
      btn_put.disabled = false;
      status.textContent = "";
    });
  });

  // 新規ボタン押下時に、ID以外の入力欄を有効化する
  btn_new.addEventListener('click', function(event) {
    input_id.value = "";
    input_s_name.value = "";
    input_name.value = "";
    input_address.value = "";
    input_tel.value = "";
    input_email.value = "";
    input_id.disabled = true;
    input_s_name.disabled = true;
    input_name.disabled = false;
    input_address.disabled = false;
    input_tel.disabled = false;
    input_email.disabled = false;
    btn_new.disabled = true;
    btn_get.disabled = true;
    btn_put.disabled = true;
    btn_del.disabled = true;
    btn_rel.disabled = false;
    btn_sch.disabled = true;
    status.textContent = "";
  });

  // 取得ボタン押下時に、IndexedDBから値を取得し、入力欄を有効化
  btn_get.addEventListener('click', function(event) {
    const tx = db.transaction('friends', 'readonly');
    const store = tx.objectStore('friends');
    //const request = store.getAll(IDBKeyRange.only(input_id.value), 1);
    const request = store.get(input_id.value);

    request.onsuccess = () => {
      if (!request.result) {
        status.textContent = "指定されたデータがありません。";
        return;
      }
      input_name.value = request.result.name;
      input_address.value = request.result.address;
      input_tel.value = request.result.tel;
      input_email.value = request.result.email;
      input_name.disabled = false;
      input_address.disabled = false;
      input_tel.disabled = false;
      input_email.disabled = false;
      btn_put.disabled = true;
      status.textContent = "読込完了"
    }

    request.onerror = () => {
      status.textContent = "エラー発生"
    }
  });

  // 書込ボタン押下時に、IndexedDBに値を保存する
  btn_put.addEventListener('click', function(event) {

    if (totalCount >= MAX_COUNT) {
      status.textContent = `登録件数が最大値(${MAX_COUNT}件)を超えているので、登録できません。`;
      return;
    }

    if (!input_id.value && !input_name.value && !input_address.value && !input_tel.value && !input_email.value) {
      input_id.focus();
      status.textContent = "値が入力されていません。";      
      return;
    }

    const phoneRegex = /^0\d{1,4}-?\d{1,4}-?\d{3,4}$/;
    if (!phoneRegex.test(input_tel.value)) {
      status.textContent = "電話番号の形式が正しくありません";
      input_tel.focus();
      return;
    }

    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(input_email.value)) {
      status.textContent = "MAILの形式が正しくありません";
      input_email.focus();
      return;
    }

    const tx = db.transaction('friends', 'readwrite');
    const store = tx.objectStore('friends');

    const data = {
      id: input_id.value,
      name: input_name.value,
      address: input_address.value,
      tel: input_tel.value,
      email: input_email.value
    };

    // IDが存在すれば、上書き、なければ、新たにIDを付与し追加
    if (input_id.value) {
      store.put(data);
    } else {
      lastId++;
      data.id = lastId.toString();
      store.add(data);
    }

    tx.oncomplete = () => {
      status.textContent = "保存完了";
      document.dispatchEvent(new CustomEvent('reloadTable', {
        detail: { storename: 'friends' }
      }));
    };

    tx.onerror = () => {
      status.textContent = "エラー発生";
      lastId--;
    };
    
    input_id.value = "";
    input_name.value = "";
    input_address.value = "";
    input_tel.value = "";
    input_email.value = "";
    input_id.disabled = false;
    input_s_name.disabled = false;
    input_name.disabled = true;
    input_address.disabled = true;
    input_tel.disabled = true;
    input_email.disabled = true;
    btn_new.disabled = false;
    btn_get.disabled = true;
    btn_put.disabled = true;
    btn_del.disabled = true;
    btn_rel.disabled = true;
  });

  // 削除ボタン押下時に、IndexedDBから値を削除
  document.getElementById("del").addEventListener('click', function(event) {

    const tx = db.transaction('friends', 'readwrite');
    const store = tx.objectStore('friends');

    store.delete(input_id.value);

    tx.oncomplete = () => {
      status.textContent = "削除完了";
      document.dispatchEvent(new CustomEvent('reloadTable', {
        detail: { storename: 'friends' }
      }));
    };

    tx.onerror = () => {
      status.textContent = "エラー発生";
    };
    
    input_id.value = "";
    input_name.value = "";
    input_address.value = "";
    input_tel.value = "";
    input_email.value = "";
    input_id.disabled = false;
    input_name.disabled = true;
    input_address.disabled = true;
    input_tel.disabled = true;
    input_email.disabled = true;
    btn_new.disabled = false;
    btn_get.disabled = true;
    btn_put.disabled = true;
    btn_del.disabled = true;
    btn_rel.disabled = true;
  });

  // 解除ボタン押下時に、入力欄をクリアし、新規ボタン押下前に戻す。
  document.getElementById("rel").addEventListener('click', function(event) {
    input_id.value = "";
    input_name.value = "";
    input_address.value = "";
    input_tel.value = "";
    input_email.value = "";
    input_id.disabled = false;
    input_s_name.disabled = false;
    input_name.disabled = true;
    input_address.disabled = true;
    input_tel.disabled = true;
    input_email.disabled = true;
    btn_new.disabled = false;
    btn_get.disabled = true;
    btn_put.disabled = true;
    btn_del.disabled = true;
    btn_rel.disabled = true;
  });

  // 検索ボタン押下時に、入力欄をクリアし、新規ボタン押下前に戻す。
  document.getElementById("sch").addEventListener('click', function(event) {
    const tx = db.transaction('friends', 'readonly');
    const store = tx.objectStore('friends');
    const index = store.index("nameIndex");
    const request = index.get(input_s_name.value);

    request.onsuccess = () => {
      if (!request.result) {
        status.textContent = "指定されたデータがありません。";
        return;
      }
      input_id.value = request.result.id;
      input_name.value = request.result.name;
      input_address.value = request.result.address;
      input_tel.value = request.result.tel;
      input_email.value = request.result.email;
      input_name.disabled = false;
      input_address.disabled = false;
      input_tel.disabled = false;
      input_email.disabled = false;
      btn_put.disabled = true;
      btn_del.disabled = false;
      status.textContent = "読込完了"
    }

    request.onerror = () => {
      status.textContent = "エラー発生"
    }
  });

  // 全削除ボタン押下時に、IndexedDBから全値を削除
  document.getElementById("all-del").addEventListener('click', function(event) {

    const tx = db.transaction('friends', 'readwrite');
    const store = tx.objectStore('friends');

    store.clear();

    tx.oncomplete = () => {
      status.textContent = "全削除完了";
      document.dispatchEvent(new CustomEvent('reloadTable', {
        detail: { storename: 'friends' }
      }));
    };

    tx.onerror = () => {
      status.textContent = "エラー発生";
    };
    
    input_id.value = "";
    input_s_name.value = "";
    input_name.value = "";
    input_address.value = "";
    input_tel.value = "";
    input_email.value = "";
    input_id.disabled = false;
    input_name.disabled = true;
    input_address.disabled = true;
    input_tel.disabled = true;
    input_email.disabled = true;
    btn_new.disabled = false;
    btn_get.disabled = true;
    btn_put.disabled = true;
    btn_del.disabled = true;
    btn_rel.disabled = true;
    btn_sch.disabled = true;
  });

  // カスタムイベント発生時、IndexedDBから全データ取得し、テーブルを再表示
  document.addEventListener('reloadTable', (e) => {
    const tx = db.transaction(e.detail.storename, 'readonly');
    const store = tx.objectStore(e.detail.storename);

    tableObj.setHeader();

    lastId = 0;
    totalCount = 0;
    const results = []

    const request = store.openCursor();
    request.onsuccess = (event) => {
      const cursor = event.target.result;
      if (!cursor) return

      results.push(cursor.value)
      tableObj.setData(cursor.value);
      lastId = Number(cursor.value.id);
      totalCount = results.length;

      if (results.length < MAX_COUNT) {
          cursor.continue();
      }
    }
  });

  // 初期処理
  status.textContent = "データベースオープン中";

  const request = indexedDB.open('MyDatabase', 1);

  request.onupgradeneeded = (event) => {
    db = event.target.result;
    const store = db.createObjectStore('friends', { keyPath: 'id' });
    store.createIndex("nameIndex", "name", { unique: true });
  };

  request.onsuccess = (event) => {
    db = event.target.result;
    document.dispatchEvent(new CustomEvent('reloadTable', {
      detail: { storename: 'friends' }
    }));
    input_id.disabled=false;
    btn_new.disabled=false;
    status.textContent = "";
  };
});

4. JavaScript(テーブルクラス:table_class.js)

//
// テーブル拡張クラス用JavaScriptコード
//
class tableClass {

  // コンストラクター(テーブルを生成するdivタグとテーブルの定義をうけとる)
  constructor(target, fields) {
    this.fields = fields;
    this.table = document.createElement("table");
    document.getElementById(target).append(this.table);
  }

  // テーブル定義に従い、テーブルのヘッダー部を作成
  setHeader() {
    this.table.innerHTML = "";
    const trec = document.createElement('tr');
    this.fields.forEach((field) => {
      const theader = document.createElement('th');
      theader.classList.add(field.class);
      theader.textContent = field.content;
      trec.append(theader);
    });
    this.table.appendChild(trec);
  }

  // 1行分のデータを受け取り、テーブル定義に従ってテーブルのデータ部を作成
  setData(data) {
    const trec = document.createElement('tr');
    this.fields.forEach((field) => {
      const tdata = document.createElement('td');
      tdata.textContent = data[field.name];
      trec.append(tdata);
    });
    this.table.appendChild(trec);
  }

  // データを配列で受け取り、テーブル定義に従ってテーブルを作成
  resetDatas(datas) {
    this.setHeader();
    datas.forEach((data) => {
      const trec = document.createElement('tr');
      this.fields.forEach((field) => {
        const tdata = document.createElement('td');
        tdata.textContent = data[field.name];
        trec.append(tdata);
      });
      this.table.appendChild(trec);
    });
  }
}

(function (root, factory) {
    if (typeof module === 'object' && typeof module.exports === 'object') {
        // Node.js環境
        module.exports = factory();
    } else {
        // ブラウザ環境
        root.tableClass = factory();
    }
}(this, function () {
    return tableClass;
}));

(つづく)