📊

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

2024/03/29に公開
1

SupabaseではSQLだけでなく、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.tsxpage.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,
        },
      });

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

実装確認

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

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

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

その他参考資料

パブリックリポジトリ
https://github.com/TodoONada/nextjs-supabase-graphql

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

お問合せ:GoogleForm

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

運営会社:TodoONada株式会社

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

TodoONada開発ブログ

Discussion

takubiitakubii

わからない部分があったので質問失礼します。

④:GraphQLの利用準備の部分のnpm run compileでエラーが出てファイルが出力されないのですが、Supabase側で何か設定など行なっているでしょうか?

エラー内容以下のようになります。(名前等あったので一部削除した部分もあります)

npm run compile

> compile
> graphql-codegen --require dotenv/config --config codegen.ts dotenv_config_path=.env.local

✔ Parse Configuration
⚠ Generate outputs
  ❯ Generate to ./gql/__generated__/
    ✖
      Failed to load schema from https://vkivfgsuozqpvcecwjhd.supabase.co/graphql/v1?apikey=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZraXZmZ3N1b3pxcHZjZWN3amhkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3M…
      Unexpected empty "data" and "errors" fields
      GraphQLError: Unexpected empty "data" and "errors" fields
      ...
      GraphQL Code Generator supports:
      - ES Modules and CommonJS exports (export as default or named export "schema")
      - Introspection JSON File
      - URL of GraphQL endpoint
      - Multiple files with type definitions (glob expression)
      - String in config file
      Try to use one of above options and run codegen again.
    ◼ Load GraphQL documents
    ◼ Generate