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

SupabaseではSQLだけでなく、GraphQLを利用したデータ取得も行うことができます。
今回はGraphQLでデータを取得して、添付画像のような集計アプリを作ってみます。

<img width="291.59999999999997" alt="スクリーンショット 2024-03-13 202325.png (28.0 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/13/161342/9b25d4b1-24cd-4b9b-bf4a-bd87fa927fbb.png">

# Supabaseの準備
## ①：Supabaseプロジェクトの作成
Supabaseにログイン（登録していないければアカウント登録から）して、
トップページの「New Project」を押します。
<img width="333" alt="image3.png (24.2 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2023/12/23/161342/e2c2a136-ae02-4e17-9ad5-8e902f64e39d.png">

すると、下記の様な画面が表示されます。
<img width="706" alt="image6.png (141.6 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2023/12/23/161342/5ad2d46b-636c-44c2-87a4-b25edfeb9f69.png">
適当なプロジェクト名とデータベースのパスワードを入れて新しいプロジェクトを作成しましょう。
※Regionはできれば自分の住んでいる地域の近くがいいです。私は日本にしました。

## ②：Supabaseのテーブル作成
今回集計アプリを作成するにあたってユーザ情報のテーブルと売買情報のテーブルを準備します。
まずはユーザ情報のテーブルを作成します。
Supabaseのダッシュボードから`Table Editor`を選択し、`New Table`をクリックします。
添付画像のような設定で作成しましょう。
<img width="441" alt="スクリーンショット 2024-03-12 214451.png (61.6 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/12/161342/0d62fde4-adfd-4866-acf6-ccce631f33d1.png">

次に売買情報のテーブルを作成します。
同じように`New Table`まで進み、添付画像のような設定で作成してください。
<img width="447" alt="スクリーンショット 2024-03-12 214636.png (66.2 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/12/161342/73cfc8bf-1947-406a-a061-f9403a586458.png">

またユーザIDをユーザテーブルの情報から参照するため外部キー設定を添付画像のように行ってください。
<img width="299.25" alt="スクリーンショット 2024-03-12 214655.png (52.2 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/12/161342/3dc92ca8-fe24-48c8-82a8-becf04e5a820.png">

## ③：ポリシー作成
不必要かもしれませんが念のため各テーブルにポリシーを設定します。
両テーブル共に誰でもselect（データ取得）だけできる状態にしたいので、
下記のような設定でポリシーを作成してください。（両方のテーブルに適用する必要があります）
<img width="698.4" alt="スクリーンショット 2024-03-12 220113.png (44.6 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/12/161342/68b4b046-06fc-4d66-855a-a8935788fa05.png">


## ④：テーブルデータのインポート
各テーブルにそれぞれ添付のCSVをインポートしてください。
（自分で適当にデータを作成してもOKです）
[users_rows.csv (6.1 kB)](https://esa-storage-tokyo.s3-ap-northeast-1.amazonaws.com/uploads/production/attachments/20920/2024/03/12/161342/e3a8e6f9-3a3a-405f-98bd-fd0d40ce85f5.csv)
[sales_rows.csv (6.3 kB)](https://esa-storage-tokyo.s3-ap-northeast-1.amazonaws.com/uploads/production/attachments/20920/2024/03/12/161342/8cc2b3fb-08e4-4fff-b763-9cca906d2aaa.csv)

## ⑤：GraphQLのクエリ作成
Supabase側で行う一番重要な作業になります。
GraphQLのクエリにはあくまでCRUD（作成、読み出し、更新、削除）のそれぞれに該当した基本的な機能しかないため、詳細な実装はNext.jsに任せてここではデータの取得のみ行います。
うれしいことにGraphQLは外部キーを参照していると参照先のデータを自動で結び付けてくれるので、今回は`sales`テーブルに対する取得クエリのみ作成します。

まずダッシュボードの`API Docs`を開き、`GraphiQL`にアクセスします。
<img width="829.8" alt="スクリーンショット 2024-03-12 220351.png (56.1 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/12/161342/325516a9-0e65-4404-86c1-9e9dccb1b498.png">
この画面でクエリを試すことができるため、下記のようにクエリを作成し、実行ボタンを押して確認してみましょう。
```graphql
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
}
```

これを実行すると画面右に実行結果が表示されるのがわかるかと思います。
<img width="432.59999999999997" alt="スクリーンショット 2024-03-12 220735.png (37.4 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/12/161342/cc4bf064-7e47-4220-93c5-d313cf6bc16f.png">
実際にデータを見てもらうとわかるかと思いますが、結果のデータは一度に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`にコピペしてください。
```.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
```
を実行して添付のような画面が表示されればプロジェクトの作成成功です。

<img width="1981" alt="image41.png (319.7 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2023/12/23/161342/9b97c611-1e70-455c-9710-40d2c9e49164.png">

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

<img width="193" alt="image4.png (171.3 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2023/12/23/161342/2b47a047-93d8-429a-b1c2-8a25edea68e9.png">

不要なファイル削除＆入れ替え後
<img width="193.2" alt="スクリーンショット 2024-03-13 092652.png (41.1 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/13/161342/59f44be8-e982-4595-886b-3fb8d9290175.png">


削除するファイルは、
- 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

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

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

## ③：下準備
必要なファイルをいくつか作成・変更していきます。
### utils/supabase/client.ts
Supabaseに共通でアクセスするためのクライアントを作成します。
```utils/supabase/client.ts
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のインポート以外は全て消しておきます。
```app/globals.css
@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`を作成します。
```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
```tsx
// 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のクエリを定義する記述を作成します。
```gql/constants.ts
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`に登録しておくとよいです)
<img width="153.6" alt="スクリーンショット 2024-03-13 100409.png (5.7 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/13/161342/5f5118c2-54ad-48fc-a487-4af1e2bd22d5.png">

## ⑤：基本レイアウト実装
アプリ全体を覆う`layout.tsx`や`page.tsx`を編集します。

### app/layout.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を利用するための設定を追加しています。

```app/page.tsx
"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
まずはチャートアプリ側で利用する関数群を定義します。
```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](https://www.chartjs.org/)を使って作成するため、
下記のライブラリをインストールしてください。

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

コードはこちらになります。
```components/chart/ChartApp.tsx
"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のクエリ実行→データ整形→チャート表示まで対応しています。

```components/GqlGetData.tsx
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のクエリを実行して取得します。
次のページがあったら再帰的に実行する仕組みです。
```tsx
const { data: queryData } = await client.query({
        query: salesQuery,
        variables: {
          orderBy: [
            {
              id: OrderByDirection.AscNullsLast,
            },
          ],
          after: cursor,
        },
      });
```

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

# 実装確認
では`npm run dev`で実際に実行してみましょう。
ローディングが終わると下記のようにチャートが二つ表示されていることがわかるかと思います。
<img width="291.59999999999997" alt="スクリーンショット 2024-03-13 202325.png (28.0 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/13/161342/9b25d4b1-24cd-4b9b-bf4a-bd87fa927fbb.png">

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

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

# その他参考資料
パブリックリポジトリ
[https://github.com/TodoONada/nextjs-supabase-graphql](https://github.com/TodoONada/nextjs-supabase-graphql)

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

お問合せ：[GoogleForm](https://forms.gle/CsUChSVVJUV5BxDF8)

ホームページ：https://libproc.com

運営会社：[TodoONada株式会社](https://todoonada.co.jp/)

Twitter：https://twitter.com/Todoonada_corp

Instagram：https://www.instagram.com/todoonada_corp/

Youtube：https://www.youtube.com/@todoonada_corp/

Tiktok：https://www.tiktok.com/@todoonada_corp