Next.js + SupabaseでGraphQLを利用する方法
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側から呼び出すために必要な設定を行います。
こちらの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から実行できるため試してみるのもよいと思います。
その他参考資料
パブリックリポジトリ
https://github.com/TodoONada/nextjs-supabase-graphql
またTodoONada株式会社では、この記事で紹介した以外にも、各種チャットシステムやストレージを利用した画像アプリの作成方法についてご紹介しています。下記記事にて一覧を見ることができますので、ぜひこちらもご覧ください。
お問合せ:GoogleForm
ホームページ:https://libproc.com
運営会社:TodoONada株式会社
Twitter:https://twitter.com/Todoonada_corp
Instagram:https://www.instagram.com/todoonada_corp/
Discussion
わからない部分があったので質問失礼します。
④:GraphQLの利用準備の部分の
npm run compile
でエラーが出てファイルが出力されないのですが、Supabase側で何か設定など行なっているでしょうか?エラー内容以下のようになります。(名前等あったので一部削除した部分もあります)