見出し画像

Next.js + SupabaseでGraphQLを利用する方法

ここまでSupabaseによるアプリ開発方法をいくつかご紹介してきましたが、ぜひ使ってほしい機能が一つあります。
それが『GraphQL』です。

そもそも『GraphQL』とは何か、というところからご説明すると、『GraphQL』はMeta社(旧Facebook)が開発したAPI用のクエリ言語であり、データの取得時に優れたパフォーマンスを発揮します。

通常のREST API等からユーザーの"名前"と"年齢"を取得しようとした場合、ユーザーの情報すべてがAPIから返ってきて、その中から"名前"と"年齢"を抜き出す必要が出てくるでしょう。
しかしこれでは非効率ですよね。

そこで『GraphQL』を利用するならば、ユーザーの"名前"と"年齢"だけを要求し、それだけを受け取ることができるのです。
そのため処理・送信・応答すべてを軽くすることが可能となり、サービス全体のパフォーマンスを上げることができます。

今回はGraphQLでデータを取得して、添付画像のような集計アプリを作ってみましょう。

Supabaseの準備

①:Supabaseプロジェクトの作成

Supabaseにログイン(登録していないければアカウント登録から)して、
トップページの「New Project」を押します。

すると、下記の様な画面が表示されます。


適当なプロジェクト名とデータベースのパスワードを入れて新しいプロジェクトを作成しましょう。
※Regionはできれば自分の住んでいる地域の近くがいいです。私は日本にしました。

②:Supabaseのテーブル作成

今回集計アプリを作成するにあたってユーザ情報のテーブルと売買情報のテーブルを準備します。
まずはユーザ情報のテーブルを作成します。
Supabaseのダッシュボードから`Table Editor`を選択し、`New Table`をクリックします。
添付画像のような設定で作成しましょう。

次に売買情報のテーブルを作成します。
同じように`New Table`まで進み、添付画像のような設定で作成してください。

またユーザIDをユーザテーブルの情報から参照するため、外部キー設定を添付画像のように行ってください。

③:ポリシー作成

不必要かもしれませんが、念のため各テーブルにポリシーを設定します。
両テーブル共に誰でもselect(データ取得)だけできる状態にしたいので、
下記のような設定でポリシーを作成してください。(両方のテーブルに適用する必要があります)

④:テーブルデータのインポート

各テーブルにそれぞれ添付のCSVをインポートしてください。
(自分で適当にデータを作成してもOKです)
users_rows.csv (6.1 kB)
sales_rows.csv (6.3 kB)

⑤:GraphQLのクエリ作成

Supabase側で行う一番重要な作業になります。

GraphQLのクエリにはあくまでCRUD(作成、読み出し、更新、削除)のそれぞれに該当した基本的な機能しかないため、詳細な実装はNext.jsに任せてここではデータの取得のみ行います。

うれしいことにGraphQLは外部キーを参照していると参照先のデータを自動で結び付けてくれるので、今回は`sales`テーブルに対する取得クエリのみ作成します。

まずダッシュボードの`API Docs`を開き、`GraphiQL`にアクセスしてください。


この画面でクエリを試すことができるため、下記のようにクエリを作成し、実行ボタンを押して確認してみましょう。

query SalesQuery($orderBy: [salesOrderBy!], $after: Cursor) {
  salesCollection(orderBy: $orderBy, after: $after) {
    edges {
      cursor
      node {
        id
        created_at
        user_id
        purchase_date
        item_name
        price
        users {
          id
          created_at
          name
          birthday
          sex
        }
      }
    }
    pageInfo {
      startCursor
      endCursor
      hasPreviousPage
      hasNextPage
    }
  }
}

※Variablesに引数に渡す変数の指定が必要なので、下記のように設定しましょう。

{
  "orderBy": {
    "id" : "AscNullsLast"
  },
  "after": null
}

これを実行すると、画面右に実行結果が表示されます。

データを見ればわかる通り、結果のデータは一度に30個までしか表示されないため、次のページのデータを取得するために`PageInfo`を利用して再帰的に実装する必要があります。
詳細は後程Next.jsの実装で行います。

Next.js側の準備

①:Next.jsプロジェクトの作成

任意のディレクトリで、

npx create-next-app -e with-supabase

と打ち込み、
対話的にアプリケーションの名前の設定をして、プロジェクトの作成を行いましょう。

次に環境変数の設定を行います。
作成されたNext.jsプロジェクトの直下に`.env.local.example`ファイルがあるので、
このファイルを`.env.local`にリネームし、
Supabaseのサイトの`Project Settings→API`にある、Project URLとAPI Keyをそれぞれ`.env.local`にコピペしてください。

# Update these with your Supabase details from your project settings > API
# https://app.supabase.com/project/_/settings/api
NEXT_PUBLIC_SUPABASE_URL=Project URL
NEXT_PUBLIC_SUPABASE_ANON_KEY=APIキー

その後、

npm run dev

を実行して添付のような画面が表示されれば、プロジェクトの作成成功です。

②:必要なファイルの作成

今回作りたい画面に合わせて、プロジェクトのファイル構成を変更しましょう。
現状のファイル構成が下記のようになっているかと思いますが、まずは不要なファイルを削除し、必要なファイルと入れ替えて行きましょう。

不要なファイル削除&入れ替え後

削除するファイルは、

  • app/auth/callback/route.ts

  • app/auth/login/page.tsx

  • componentsフォルダの直下すべて

  • util/supabaseフォルダのclient.ts以外

  • middleware.ts

になります。

新たに作成するファイルは、

  • components/GqlGetData.tsx

  • components/chart/ChartApp.tsx

  • components/chart/ChartUtils.tsx

  • gql/constants.ts

  • codegen.ts

になります。(一旦ファイルを新規作成するのみで大丈夫です。中身は後で作ります)
多いので一個ずつ画像を見ながら作成すると良いと思います。

では、各ファイルの中身を作成する前に下準備から行いましょう。

③:下準備

必要なファイルをいくつか作成・変更していきます。

utils/supabase/client.ts

Supabaseに共通でアクセスするためのクライアントを作成します。

import { createClient } from '@supabase/supabase-js'

export const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);

.env.localに入力したURLとAPIキーがここで利用されます。

app/globals.css

tailwindcssのインポート以外は全て消しておきます。

@tailwind base;
@tailwind components;
@tailwind utilities;

④:GraphQLの利用準備

GraphQLのクエリをNext.js側から呼び出すために必要な設定を行います。
https://www.apollographql.com/docs/react/
こちらのApollo Clientを利用するとシンプルにGraphQLを利用できます。

まずは必要なライブラリのインストールを行います。

npm i @apollo/client graphql
npm i -D @graphql-codegen/cli @graphql-codegen/client-preset

型の生成を行うためのコマンドを`package.json`に追加します。

"scripts": {
"compile": "graphql-codegen --require dotenv/config --config codegen.ts dotenv_config_path=.env.local"
}

codegen.ts

次にGraphQLのコード生成用に利用する`codegen.ts`を作成します。

import { CodegenConfig } from "@graphql-codegen/cli";

const config: CodegenConfig = {
  schema: {
    [process.env.NEXT_PUBLIC_SCHEMA_URL!]: {
      headers: {
        apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
      },
    },
  },

  documents: ["**/*.tsx", "**/*.ts"],

  generates: {
    "./gql/__generated__/": {
      preset: "client",
      plugins: [],
      presetConfig: {
        gqlTagName: "gql",
      },
    },
  },
  ignoreNoDocuments: true,
};

export default config;

※4月17日追記
schemaでURLパラメータを指定できなくなってしまったため、
schemaの記述を変更し、headerを設定するようにしました。
github: https://github.com/TodoONada/nextjs-supabase-graphql/commit/0e0bce70bb291138fafca4dae8bae473cd8256c6

// before
schema: `${process.env.NEXT_PUBLIC_SUPABASE_URL}/graphql/v1?apikey=${process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY}`,

// after
schema: {
    [process.env.NEXT_PUBLIC_SCHEMA_URL!]: {
      headers: {
        apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
      },
    },
  },

gql/constants.ts

最後に先ほど作成したGraphQLのクエリを定義する記述を作成します。

import { gql } from '@apollo/client'

// コンパイル後はこちらを利用
// import { gql } from "./__generated__";

/** セールステーブル一覧取得クエリー */
export const salesQuery = gql(`
query SalesQuery($orderBy: [salesOrderBy!], $after: Cursor) {
  salesCollection(orderBy: $orderBy, after: $after) {
    edges {
      cursor
      node {
        id
        created_at
        user_id
        purchase_date
        item_name
        price
        users {
          id
          created_at
          name
          birthday
          sex
        }
      }
    }
    pageInfo {
      startCursor
      endCursor
      hasPreviousPage
      hasNextPage
    }
  }
}
`);

これでコードは作成できたのでコンパイルしましょう。

npm run compile

これを行うとgqlフォルダの直下に`generated`が作成され、その中にファイルが生成されることが確認できると思います。
(生成されたファイルは`.gitignore`に登録しておくとよいです)

⑤:基本レイアウト実装

アプリ全体を覆う`layout.tsx`や`page.tsx`を編集します。

app/layout.tsx

ヘッダーをなくしてシンプルにした形です。

import "./globals.css";

const defaultUrl = process.env.VERCEL_URL
  ? `https://${process.env.VERCEL_URL}`
  : "http://localhost:3000";

export const metadata = {
  metadataBase: new URL(defaultUrl),
  title: "Next.js and Supabase Starter Kit",
  description: "The fastest way to build apps with Next.js and Supabase",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body className="bg-background text-foreground">
        <main className="min-h-screen flex flex-col items-center">
          {children}
        </main>
      </body>
    </html>
  );
}

app/page.tsx

Apollo Clientを利用するための設定を追加しています。

"use client";
import GqlGetData from "@/components/GqlGetData";
import {
  ApolloClient,
  ApolloProvider,
  createHttpLink,
  InMemoryCache,
} from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
import { supabase } from "@/utils/supabase/client";

const httpLink = createHttpLink({
  uri: `${process.env.NEXT_PUBLIC_SUPABASE_URL}/graphql/v1`,
});

const authLink = setContext(async (_, { headers }) => {
  const session = (await supabase.auth.getSession()).data.session;
  return {
    headers: {
      ...headers,
      authorization: `Bearer ${
        session
          ? session.access_token
          : process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
      }`,
      apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
    },
  };
});

const apolloClient = new ApolloClient({
  uri: `${process.env.NEXT_PUBLIC_SUPABASE_URL}/graphql/v1`,
  link: authLink.concat(httpLink),
  cache: new InMemoryCache(),
});
export default async function Index() {
  return (
    <div className="flex-1 w-full flex flex-col gap-4 items-center">
      <h1 className="pt-10 text-xl">集計APIのサンプル</h1>
      <ApolloProvider client={apolloClient}>
        <GqlGetData></GqlGetData>
      </ApolloProvider>
    </div>
  );
}

⑥:主要部分の実装

GraphQLを実行し、チャートで表現する仕組みを作成していきます。

components/chart/ChartUtils.ts

まずはチャートアプリ側で利用する関数群を定義します。

export const getAgeGroup = (birthday: any) => {
  const age = Math.floor(
    (new Date().getTime() - new Date(birthday).getTime()) / 3.15576e10
  );
  return Math.floor(age / 10) * 10;
};

export const sortDataMap = (dataMap: Map<number, number>) => {
  return new Map(
    Array.from(dataMap).sort((a, b) => {
      if (a[0] > b[0]) {
        return 1;
      } else if (a[0] < b[0]) {
        return -1;
      }
      return 0;
    })
  );
};

export const initAgeDataMap = () => {
  const map = new Map<number, number>();
  map.set(10, 0);
  map.set(20, 0);
  map.set(30, 0);
  map.set(40, 0);
  map.set(50, 0);
  return map;
};

export const initMaleOrFemaleMap = () => {
  const map = new Map<String, number>();
  map.set("りんご", 0);
  map.set("みかん", 0);
  map.set("バナナ", 0);
  return map;
};

誕生日から年代を計算したり、データをソートするなどを行っています。

components/chart/ChartApp.tsx

チャート部分の実装です。
今回チャートをChart.jsを使って作成するため、
下記のライブラリをインストールしてください。

npm install --save chart.js react-chartjs-2

コードはこちらになります。

"use client";
import {
  Chart as ChartJS,
  CategoryScale,
  LinearScale,
  BarElement,
  Title,
  Tooltip,
  Legend,
} from "chart.js";
import { Bar } from "react-chartjs-2";

export default function ChartApp(props: { title: string; chartData: any }) {
  ChartJS.register(
    CategoryScale,
    LinearScale,
    BarElement,
    Title,
    Tooltip,
    Legend
  );

  const options = {
    responsive: true,
    plugins: {
      legend: {
        position: "top" as const,
      },
      title: {
        display: true,
        text: props.title,
      },
    },
  };

  console.log(props.chartData);

  if (props.chartData != undefined) {
    return (
      <div>
        <Bar options={options} data={props.chartData} />
      </div>
    );
  } else {
    return <div>Loading...</div>;
  }
}

components/GqlGetData.tsx

GqlGetDataではGraphQLのクエリ実行→データ整形→チャート表示まで対応しています。

import { useState, useEffect } from "react";
import { gql, useApolloClient } from "@apollo/client";
import { OrderByDirection } from "@/gql/__generated__/graphql";
import { salesQuery } from "@/gql/constants";
import ChartApp from "./chart/ChartApp";
import {
  getAgeGroup,
  initAgeDataMap,
  initMaleOrFemaleMap,
  sortDataMap,
} from "./chart/ChartUtils";

// GraphQLからのデータ取得を担う関数
export default function GqlGetData() {
  const client = useApolloClient();
  const [dataOfAgeLayer, setDataOfAgeLayer] = useState<any>();
  const [dataOfMaleOrFemale, setDataOfMaleOrFemale] = useState<any>();

  useEffect(() => {
    let allSales: any = [];

    const fetchSales = async (cursor: any = null) => {
      const { data: queryData } = await client.query({
        query: salesQuery,
        variables: {
          orderBy: [
            {
              id: OrderByDirection.AscNullsLast,
            },
          ],
          after: cursor,
        },
      });

      const salesCollection = queryData?.salesCollection;

      if (salesCollection != undefined) {
        allSales = allSales.concat(
          salesCollection.edges.map((edge) => edge.node)
        );

        if (salesCollection.pageInfo.hasNextPage) {
          await fetchSales(salesCollection.pageInfo.endCursor);
        }
      }
    };

    // 何歳の人がどの果物を何回買っているか?
    const getDataOfAgeLayer = async () => {
      const appleMap = initAgeDataMap();
      const orangeMap = initAgeDataMap();
      const bananaMap = initAgeDataMap();
      for (let i = 0; i < allSales.length; i++) {
        const saleElement = allSales[i];
        const ageGroup = getAgeGroup(saleElement["users"]["birthday"]);
        switch (saleElement["item_name"]) {
          case "りんご":
            const currentAppleValue = appleMap.get(ageGroup);
            appleMap.set(ageGroup, currentAppleValue! + 1);
            break;

          case "みかん":
            const currentOrangeValue = orangeMap.get(ageGroup);
            orangeMap.set(ageGroup, currentOrangeValue! + 1);
            break;

          case "バナナ":
            const currentBananaValue = bananaMap.get(ageGroup);
            bananaMap.set(ageGroup, currentBananaValue! + 1);
            break;

          default:
            break;
        }
      }

      const labels = ["10", "20", "30", "40", "50"];
      const appleData: any[] = [];
      const orangeData: any[] = [];
      const bananaData: any[] = [];
      appleMap.forEach((value, key) => {
        appleData.push(value);
      });
      orangeMap.forEach((value, key) => {
        orangeData.push(value);
      });

      bananaMap.forEach((value, key) => {
        bananaData.push(value);
      });

      const chartData = {
        labels,
        datasets: [
          {
            label: "リンゴ",
            data: appleData,
            backgroundColor: "rgba(255, 0, 0, 0.5)",
          },
          {
            label: "みかん",
            data: orangeData,
            backgroundColor: "rgba(0, 255, 0, 0.5)",
          },
          {
            label: "バナナ",
            data: bananaData,
            backgroundColor: "rgba(0, 0, 255, 0.5)",
          },
        ],
      };
      setDataOfAgeLayer(chartData);
    };

    // 各果物につき男性女性どちらが何回買っているか
    const getDataOfMaleOrFemale = async () => {
      const maleMap = initMaleOrFemaleMap();
      const femaleMap = initMaleOrFemaleMap();
      for (let i = 0; i < allSales.length; i++) {
        const saleElement = allSales[i];
        switch (saleElement["users"]["sex"]) {
          case 0:
            const currentMaleValue = maleMap.get(saleElement["item_name"]);
            maleMap.set(saleElement["item_name"], currentMaleValue! + 1);
            break;

          case 1:
            const currentFemaleValue = femaleMap.get(saleElement["item_name"]);
            femaleMap.set(saleElement["item_name"], currentFemaleValue! + 1);
            break;

          default:
            break;
        }
      }

      const labels = ["りんご", "みかん", "バナナ"];
      const maleData: any[] = [];
      const femaleData: any[] = [];
      maleMap.forEach((value, key) => {
        maleData.push(value);
      });
      femaleMap.forEach((value, key) => {
        femaleData.push(value);
      });

      const chartData = {
        labels,
        datasets: [
          {
            label: "男性",
            data: maleData,
            backgroundColor: "rgba(0, 0, 255, 0.5)",
          },
          {
            label: "女性",
            data: femaleData,
            backgroundColor: "rgba(255, 0, 0, 0.5)",
          },
        ],
      };
      setDataOfMaleOrFemale(chartData);
    };

    const init = async () => {
      await fetchSales();

      await getDataOfAgeLayer();
      await getDataOfMaleOrFemale();
    };
    init();
  }, [client]);

  return (
    <div>
      <ChartApp
        title="何歳代の人が累計何回購入したか?"
        chartData={dataOfAgeLayer}
      ></ChartApp>
      <ChartApp
        title="各果物を男性、女性がそれぞれ何個買ったか?"
        chartData={dataOfMaleOrFemale}
      ></ChartApp>
    </div>
  );
}

`fetchSales`関数でsalesテーブルのデータをGraphQLのクエリを実行して取得します。
次のページがあったら再帰的に実行する仕組みです。

const { data: queryData } = await client.query({
        query: salesQuery,
        variables: {
          orderBy: [
            {
              id: OrderByDirection.AscNullsLast,
            },
          ],
          after: cursor,
        },
      });

詳細な実装の説明は割愛しますが、`getDataOfAgeLayer`と`getDataOfMaleOrFemale`でチャート表示に必要な処理を行っています。

実装確認

では`npm run dev`で実際に実行してみましょう。
ローディングが終わると下記のようにチャートが二つ表示されていることがわかるかと思います。

また、コンソールを見るとデータがどのような形でチャートに追加されているかが表示されているため、確認してみてください。

これで今回の実装は完了です。GraphQLでデータを扱う方法が理解できたかと思います。
更新や削除などもGraphQLから実行できるため、是非試してみてください。

その他参考資料

今回のgithubはこちらです!

またTodoONada株式会社では、この記事で紹介した以外にも、各種チャットシステムやストレージを利用した画像アプリの作成方法についてご紹介しています。
下記記事にて一覧を見ることができますので、ぜひこちらもご覧ください!

お問合せ&各種リンク

presented by

サポートしていただくと、筆者のやる気がガンガンアップします!