虎の穴開発室ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

ブラウザでも使えるSQL「AlaSQL」を試そう ~動作確認から検索画面まで~

みなさんこんにちは。
2021 年もお元気ですか? おっくんです。

皆さん SQL 実行してますか?
「CLI で」「DB クライアントで」「ORM を介して」などのいろいろなパターンがあると思いますが、 今回はブラウザで SQL を実行する方法として「AlaSQL」を紹介し、最後に検索画面の実装まで試そうと思います。

初めに

今回使用する AlaSQL を紹介します。

github.com

AlaSQLは、JavaScriptのためのオープンソースのインメモリSQLデータベースです。 いくつかの形式のインポートとエクスポートにより、永続化機能も提供します。 動作環境として、Webブラウザ・Node.js、およびモバイルアプリで機能します。

動作環境

  • Google Chrome 87.0.4280.141
  • Node.js 14.9.0(nodenvで導入)

コンソールで試してみる

手始めに、Chrome の開発者コンソールで動作を確認してみましょう。 AlaSQL を読み込むだけの htmlファイル test.html を用意します。

[test.html]

<html>
  <head>
    <meta charset="utf-8" />
    <title>AlaSQLテスト</title>
    <script src="https://unpkg.com/alasql@0.4.11/dist/alasql.js"></script>
  </head>
  <body></body>
</html>

test.htmlを開きます。用意したとおり、何も表示されないページです。
ブラウザのコンソールを開いて、確認していきます。

[コンソールで入力]

// テーブルを作成
alasql("CREATE Table foods (id typeid AUTOINCREMENT, name string)");
// => 1

// レコードをインサート
alasql("INSERT INTO foods(name) VALUES ('Abc'),('Def'),('Ghi')");
// => 3

// レコードを全件取得
alasql("SELECT * FROM foods");
// => (3) [{…}, {…}, {…}]
//     0: {id: 1, name: "Abc"}
//     1: {id: 2, name: "Def"}
//     2: {id: 3, name: "Ghi"}
//     length: 3
//     __proto__: Array(0)

// idを指定してselect
alasql("SELECT * FROM foods WHERE id=?", [1]);
// => [{…}]
//    0: {id: 1, name: "Abc"}
//    length: 1
//    __proto__: Array(0)

// nameを部分一致でselect
alasql("SELECT * FROM foods WHERE name like CONCAT('%',?,'%')", ["f"]);
// => [{…}]
//    0: {id: 2, name: "Def"}
//    length: 1
//    __proto__: Array(0)

// 実行結果はJavaScriptオブジェクトとしてアクセスできる
const tmp2 = alasql("SELECT * FROM foods WHERE id=?", [2]);
//=> undefined
tmp2;
// => [{…}]0: {id: 2, name: "Def"}length: 1__proto__: Array(0)
tmp2[0].id;
// => 2

一通りのテーブルの作成・検索ができることを確認できました。 ページのリロードをすることで作成したテーブルは、消失します。 テーブルを永続化する方法としてブラウザでは、LocalStrage にテーブルを作成することができます。

localStorage and DOM-storage

alasql-wiki.readthedocs.io

AlaSQL は、Node.js 環境でも使用できます。こちらでは Excel や SQLite、JSON などに書き出す形で永続化が可能です

Use AlaSQL to convert data from CSV to Excelに記載もあるので、ぜひ試してみてください

alasql-wiki.readthedocs.io

検索画面を作ってみる

ここまで AlaSQL をコンソールで動作確認してきました。 ここからは、AlaSQL を使用した検索画面を作成してみたいと思います。

検索対象のテーブルの内容は以下の通りです。

name(名称) food_type(種別) stock(在庫数) limit_date(消費期限)
豚肉 0 3 2021/01/22
牛肉 0 1 2021/01/20
レタス 2 2 2021/01/28
3 4 2021/01/26

アプリケーションの作成

以下のコマンドで、アプリケーションを用意します

npm init vite-app alasql-search

cd alasql-search

npm install

npm run dev

今回は、viteを使用して、vue.jsアプリの雛形を用意しました。

github.com

サーバーが起動しているので、http://localhost:3000 にアクセスすると次の画面が表示されていると思います。

f:id:toranoana-lab:20210121115406p:plain

追加パッケージ導入

AlaSQL と、CSS フレームワークとして Bulma を導入します。

npm install alasql bulma

bulma.io

コンポーネント作成

続けて、4 つのコンポーネントを作成します。

  • src/App.vue マウントするアプリケーション全体
    src/components/Search.vue をマウントさせます。

  • src/components/Search.vue
    検索画面全体を構成し、検索機能もこのコンポーネントに持たせます。

  • src/components/SearchForm.vue
    検索フォーム機能を提供します。

  • src/components/FoodItem.vue
    検索結果のテーブルの 1 行を構成します。

[src/App.vue]

<template>
  <Search />
</template>

<script lang="ts">
import { defineComponent } from "vue";

import Search from "./components/Search.vue";
import "bulma/css/bulma.css";

export default defineComponent({
  name: "App",
  components: {
    Search,
  },
  setup() {
    return {};
  },
});
</script>

[src/components/Search.vue]

<template>
  <main>
    <section class="section">
      <div class="container">
        <search-form :search-func="searchExec" />
        <table class="table is-striped is-fullwidth">
          <thead>
            <tr>
              <th>名前</th>
              <th>種別</th>
              <th>在庫(個)</th>
              <th>消費期限</th>
            </tr>
          </thead>
          <tbody>
            <food-item
              v-for="food of data.selected_list"
              :key="food.id"
              :food="food"
            />
          </tbody>
        </table>
      </div>
    </section>
  </main>
</template>

<script lang="ts">
import { defineComponent, reactive } from "vue";
import alasql from "alasql";
import SearchForm from "./SearchForm.vue";
import FoodItem from "./FoodItem.vue";

const defultList = () => [
  { id: 1, name: "豚肉", foodType: "0", stack: 0, limitDay: "2021/01/22" },
  { id: 2, name: "牛肉", foodType: "0", stack: 1, limitDay: "2021/01/20" },
  { id: 3, name: "レタス", foodType: "2", stack: 2, limitDay: "2021/01/28" },
  { id: 4, name: "米", foodType: "3", stack: 4, limitDay: "2021/01/26" },
];

interface State {
  selected_list: [];
  order: number | undefined;
  type: number | undefined;
  keyword: string;
}

export default defineComponent({
  name: "Search",
  components: {
    SearchForm,
    FoodItem,
  },
  setup() {
    //テーブルを作成し、バルクインサートする alasql.tables.[テーブル名].data に配列を渡す
    alasql(
      "CREATE TABLE IF NOT EXISTS foods (id typeid AUTOINCREMENT, name ,foodType, stack, limitDay )"
    );
    alasql.tables.foods.data = defultList();
    console.log(alasql("SELECT * FROM foods"));

    // バルクインサートの場合、IDのオートインクリメントの値が1のままなので、
    // IDの最大値+1 を次のIDとして使用します
    alasql.tables.foods.identities.id.value =
      alasql("SELECT max(id) as max FROM foods")[0].max + 1;

    const data = reactive({
      selected_list: alasql("SELECT * FROM foods ORDER BY stack desc"),
      order: undefined,
      type: undefined,
      keyword: "",
    });

    const searchExec = (foodType: string, keyword: string) => {
      if (foodType === "") {
        data.selected_list = alasql(
          "SELECT * FROM foods WHERE name like CONCAT('%',?,'%') ORDER BY stack desc",
          [keyword]
        );
      } else {
        data.selected_list = alasql(
          "SELECT * FROM foods WHERE foodType = ? AND name like CONCAT('%',?,'%') ORDER BY stack desc",
          [foodType, keyword]
        );
      }
    };

    return {
      data,
      searchExec,
    };
  },
});
</script>

[src/components/SearchForm.vue]

<template>
  <div class="field is-grouped">
    <div class="control">
      <input
        class="input"
        type="text"
        placeholder="食品名"
        v-model="data.keyword"
        @keydown.enter="execSearch()"
      />
    </div>

    <div class="control">
      <div class="select">
        <select v-model="data.foodType">
          <option value="" selected>すべて</option>
          <option value="0">肉</option>
          <option value="1">魚</option>
          <option value="2">野菜</option>
          <option value="3">穀類</option>
        </select>
      </div>
    </div>

    <div class="control">
      <button class="button is-primary" @click="execSearch()">在庫検索</button>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent, reactive } from "vue";

interface Props {
  searchFunc: Function;
};

// 食品在庫のアイテムの型定義
interface State {
  foodType: "" | "0" | "1" | "2" | "3";
  keyword: string;
}

export default defineComponent({
  name: "SearchForm",
  props: {
    searchFunc: {
      type: Function,
      default: (order: number, type: number, keyword: string): void => {},
    },
  },
  setup(props: Props) {
    const searchFunc = props.searchFunc as Function;

    const data = reactive<State>({
      foodType: "",
      keyword: "",
    });

    const execSearch = () => {
      searchFunc(data.foodType, data.keyword);
    };

    return { data, execSearch };
  },
});
</script>

[src/components/FoodItem.vue]

<template>
  <tr>
    <td>{{ name }}</td>
    <td>{{ foodType }}</td>
    <td>{{ stack }} 個</td>
    <td>{{ limitDay }}</td>
  </tr>
</template>

<script lang="ts">
import { defineComponent, reactive } from "vue";

interface Props {
  food: Object;
};

// 食品在庫のアイテムの型定義
interface Food {
  id: number;
  name: string;
  foodType: string;
  stack: number;
  limitDay: string;
}

export default defineComponent({
  name: "FoodItem",
  props: {
    food: {
      type: Object,
      default: (): Food => {
        return {
          id: 0,
          name: "",
          foodType: undefined,
          stack: 0,
          limitDay: "",
        };
      },
    },
  },
  setup(props: Props) {
    console.log(props.food);
    const { name, foodType, stack, limitDay } = props.food as Food;

    const convertFoodType = (foodType: string) => {
      if (foodType === "0") return "肉";
      if (foodType === "1") return "魚";
      if (foodType === "2") return "野菜";
      if (foodType === "3") return "穀物";
    };

    return { name, foodType: convertFoodType(foodType), stack, limitDay };
  },
});
</script>

npm run devで改めてサーバーを起動し、http://localhost:3000 にアクセスすると次のような動作が確認できます。

f:id:toranoana-lab:20210121122242g:plain

AlaSQL を使用して、検索処理を実装することができました。

バックエンドに投げることが必須ではない検索で、更新頻度が低く即時性が要求されないのであれば、
リクエストも減らせるので利点があると思います。

今回はソースコード中に検索対象をベタ書きしています。
実践的な利用としては、「テーブルはLocalStorage で永続化しておき、テーブルが無い時にはバックエンドのサービスからデータをもらいCERATE TABLEする」というのをイメージしています。

今回の実装では触れていない機能もあるので、ぜひ触ってみてください。

P.S.

【オンライン開催】とらのあなエンジニア&マーケター採用説明会【地方勤務可能!!】

1/29(金)に、採用説明会をオンラインにて開催します。 虎の穴ラボへの転職を考えている方や地元に住みながらWEBエンジニアの仕事に就きたい方、ちょっと話を聞いてみたい方など、ご参加をお待ちしております! yumenosora.connpass.com

カジュアル面談

弊社エンジニアと1on1で話せます、カジュアル面談も現在受付中です!こちらも是非ご検討ください。 news.toranoana.jp

その他採用情

虎の穴ラボでの開発に少しでも興味を持っていただけた方は、採用説明会やカジュアル面談という場でもっと深くお話しすることもできます。ぜひお気軽に申し込みいただければ幸いです。
カジュアル面談では虎の穴ラボのエンジニアが、開発プロセスの内容であったり、「今期何見ました?」といったオタクトークから業務の話まで何でもお応えします。

カジュアル面談や採用情報はこちらをご確認ください。
yumenosora.co.jp