使用Supabase的GraphQL功能,在Next.js中尝试创建一个简单的应用程序

你好,我是在Supabase担任DevRel的泰勒!

Supabase 是一款完全利用 PostgreSQL 的后端即服务(BaaS)平台,用户可以使用 PostgreSQL 快速构建应用程序,而无需从头开始构建后端。通常情况下,用户通过各种客户端库调用 Supabase 提供的终端点来读写数据。但实际上,Supabase 还开发了一个名为 pg_graphql 的 PostgreSQL 扩展,通过该扩展还提供了 GraphQL 的功能。Supabase 提供的 GraphQL 终端点,使用 Supabase 的 RLS(行级安全)功能,能够轻松创建安全的应用程序。

由于最近发布了pg_graphql的v1.0版本,所以我想这次正好借此机会尝试使用GraphQL来创建一个简单的应用程序。只是为了体验一下Supabase的GraphQL,我打算创建一个简单的待办事项列表应用程序!

Dec-31-2022 23-18-43.gif

另外,这次要创作的应用程序的成品我已经放在这里了,如果方便的话,请一并查看。

这次使用的堆栈

Supabase – データベースと認証認可機能

pg_graphql – PostgresのGraphQL用拡張機能

Next.js – フロントエンドフレームワーク

Tailwind CSS – スタイリング

Apollo Client – GraphQLクライアント

第一步:准备Supabase。

首先,让我们创建一个新的Supabase项目。您可以通过这个链接创建一个新的Supabase项目。

数据库.新

当完成后,我们来创建一个表。请您在Supabase的SQL编辑器中运行以下SQL命令。它将创建一个名为”tasks”的表,并为该表设置了只有登录用户才能创建任务的RLS配置。

create table if not exists tasks (
    id uuid primary key not null default gen_random_uuid(),
    user_id uuid not null references auth.users(id) default auth.uid(),
    title text not null constraint title_length_check check (char_length(title) > 0),
    is_completed boolean not null default false,
    created_at timestamptz not null default now()
);

alter table public.tasks enable row level security;
create policy "Users can select their own tasks" on public.tasks for select using (auth.uid() = user_id);
create policy "Users can insert their own tasks" on public.tasks for insert with check (auth.uid() = user_id);
create policy "Users can update their own tasks" on public.tasks for update  using (auth.uid() = user_id) with check (auth.uid() = user_id);
create policy "Users can delete their own tasks" on public.tasks for delete using (auth.uid() = user_id);

第二步:准备GraphQL相关事项

在开始编写应用程序代码之前,我们要先准备一下与GraphQL相关的类型生成工作。本次使用的Apollo Client(可能还有其他GraphQL客户端库)具备生成TypeScript类型的功能。配置好这一部分后,开发工作会更加顺利和轻松。所以我们可以悠闲地进行配置。

首先,如果没有应用程序,就无法开始任何事情,所以我们将使用这个命令创建一个Next.js应用程序。我们可以使用已经安装了TrailwindCSS的模板来开始。

npx create-next-app -e with-tailwindcss --ts

如果您能做到,就用您喜歡的編碼編輯器打開應用程序,並安裝所需的套件。

首先是supabase-js。这次只使用身份验证相关的功能。

npm i @supabase/supabase-js

而作为GraphQL客户端的Apollo Client

npm i @apollo/client graphql

最后,我们将安装一个可以自动生成Typescript类型文件的包,用于根据GraphQL的query和mutation创建。这个包的信息来源于Apollo官方的Typescript设置指南。

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

最终,在package.json的scripts中添加graphql-codegen。这样,当运行npm run compile时,将生成适用于Typescript的类型。

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

创建.env.local文件,并设置以下两个环境变量。这些值可以从Supabase管理界面中的设置页面中获取。

NEXT_PUBLIC_SUPABASE_URL=https://yourprojectref.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY

諸多的准备工作完成后,我们就可以开始编写代码了。首先,我们先创建一个文件,用于将各种常量集中存放在同一个地方。


import { createClient } from '@supabase/supabase-js'
import { gql } from '@apollo/client'

export const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
export const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!

export const supabase = createClient(supabaseUrl, supabaseAnonKey)

/** タスク一覧取得用クエリー */
export const tasksQuery = gql(`
    query TasksQuery($orderBy: [tasksOrderBy!]) {
        tasksCollection(orderBy: $orderBy) {
            edges {
            node {
                title
                is_completed
                id
            }
            }
        }
    }
`)

/** 新規タスク作成用ミューテーション */
export const taskInsert = gql(`
    mutation TaskMutation($objects: [tasksInsertInput!]!) {
        insertIntotasksCollection(objects: $objects) {
            records {
            title
            }
        }
    }
`)

/** タスクのステータス更新用ミューテーション */
export const taskUpdate = gql(`
    mutation Mutation($set: tasksUpdateInput!, $filter: tasksFilter) {
        updatetasksCollection(set: $set, filter: $filter) {
            records {
                is_completed
            }
        }
    }
`)

我使用Apollo Studio为这个文件生成了用于GraphQL的代码。对于Supabase项目,GraphQL的终点是SUPABASE_URL/graphql/v1,因此在您自己的项目中使用Apollo Studio等GraphQL工具时,请使用该URL。

现在让我们开始生成类型。在运行npm run compile之前,创建codegen.ts文件,并在其中指定我们将使用的GraphQL端点的信息以及类型文件的输出位置。

import { CodegenConfig } from '@graphql-codegen/cli';
import { supabaseAnonKey, supabaseUrl } from './src/constants';


const config: CodegenConfig = {
  // SupabaseのGraphQLエンドポイント
  // `apikey`パラメーターは手前のAPI Gatewayを通るのに必要
  schema: `${supabaseUrl}/graphql/v1?apikey=${supabaseAnonKey}`,

  // 型を生成するクエリーがどこのファイルに記載されているか。
  // 今回は`constants.ts`のみだが、今後の拡張性も考えてとりあえず全てのtsとtsxファイルを探すよう指定
  documents: ['**/*.tsx','**/*.ts'],

  // 出力したファイルをどこに置くかの指定
  generates: {
    './src/__generated__/': {
      preset: 'client',
      plugins: [],
      presetConfig: {
        gqlTagName: 'gql',
      }
    }
  },
  ignoreNoDocuments: true,
};

export default config;

如果能做到这一点,才能生成类型!请在终端上运行以下命令,生成GraphQL类型文件吧!

npm run compile

我认为在src/__generated__目录下生成了一些文件。最后,编辑constants.ts文件的导入内容,使其加载新创建的gpl文件。

可以删除这个导入。

import { gql } from '@apollo/client'

下面将进行汉语的表达:
?这样你可以在应用程序中以类型化的方式处理GraphQL响应并进行重写。

import { gql } from './__generated__'

第三步:创建主要应用程序

我们再次来说一下,这次要制作的应用程序大致是这样的。首先,用户需要进行登录,登录完成后将进入下方的任务列表界面。从该界面下方的文本框中,可以创建新的任务,并且创建的任务可以通过右侧的完成按钮进行标记为已完成或未完成。

Dec-31-2022 23-18-43.gif

首先,我们将从_app.tsx文件开始编辑。创建Apollo Client实例并传递给Provider。同时,为了使用Supabase的RLS,还需要在GraphQL端点的header中传递Supabase Auth的访问令牌。因此,根据这里提供的代码,在每次请求之前将最新的访问令牌传递进去的形式。

import '../styles/globals.css'
import type { AppProps } from 'next/app'
import {
  ApolloClient,
  ApolloProvider,
  createHttpLink,
  InMemoryCache,
} from '@apollo/client'
import { supabase, supabaseAnonKey, supabaseUrl } from '../src/constants'
import { setContext } from '@apollo/client/link/context'

const httpLink = createHttpLink({
  uri: `${supabaseUrl}/graphql/v1`,
})

const authLink = setContext(async (_, { headers }) => {
  // get the authentication token from local storage if it exists
  const session = (await supabase.auth.getSession()).data.session
  // return the headers to the context so httpLink can read them
  return {
    headers: {
      ...headers,
      authorization: `Bearer ${
        session ? session.access_token : supabaseAnonKey
      }`,
      apikey: supabaseAnonKey,
    },
  }
})

const apolloClient = new ApolloClient({
  uri: `${supabaseUrl}/graphql/v1`,
  link: authLink.concat(httpLink),
  cache: new InMemoryCache(),
})

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <ApolloProvider client={apolloClient}>
      <Component {...pageProps} />
    </ApolloProvider>
  )
}

export default MyApp

让我们来创建一个主页!由于在这个文件中进行了多种操作,所以我会进行分解并解释。

首先,由于稍微有点复杂,本次没有进行任何服务器端渲染。使用useEffect在客户端中进行数据拉取或身份验证确认。

在文件的底部定义了名为”TodoList”和”LoginForm”的组件,并根据用户的认证状态进行分别展示。当用户初次打开应用时,会显示登录表单,而当登录完成后会显示Todo列表。

在TodoList中,首先使用useQuery来获取数据的处理写在里面,在该组件被加载时会自动拉取数据。然后,用于更新数据的useMutation也被类似地定义,以便在用户提交表单或按下按钮时调用。当使用mutation更新数据时,会读取通过useQuery返回的refetchTasks,以重新加载最新数据。如果能很好地利用缓存,可能就不需要重新加载数据了,但这次我们就稍微简单点,不需要做得太复杂了,哈哈。

其他的就只剩最后一个,只是将标头作为独立组件提取出来,这只是一个很小的改动。这里是在登录状态下会显示退出按钮。

import { Session } from '@supabase/supabase-js'
import type { NextPage } from 'next'
import Head from 'next/head'
import React, { useEffect, useState } from 'react'
import { useMutation, useQuery } from '@apollo/client'
import { OrderByDirection } from '../src/__generated__/graphql'
import { supabase, taskInsert, tasksQuery, taskUpdate } from '../src/constants'

const Home: NextPage = () => {
  const [session, setSession] = useState<Session | null>(null)

  useEffect(() => {
    const getInitialSession = async () => {
      const initialSession = (await supabase.auth.getSession())?.data.session
      setSession(initialSession)
    }

    getInitialSession()

    const authListener = supabase.auth.onAuthStateChange(
      async (event, session) => {
        setSession(session)
      }
    )

    return () => {
      authListener.data.subscription.unsubscribe()
    }
  }, [])

  return (
    <div className="flex flex-col bg-black h-screen">
      <Head>
        <title>Supabase pg_graphql Example</title>
      </Head>

      <AppHeader isSignedIn={!!session} />

      <main className="text-white flex-grow max-w-4xl mx-auto min-h-0">
        {session ? <TodoList /> : <LoginForm />}
      </main>
    </div>
  )
}

export default Home

const TodoList = (): JSX.Element => {
  const {
    loading,
    data: queryData,
    refetch: refetchTasks,
  } = useQuery(tasksQuery, {
    variables: {
      orderBy: [
        {
          created_at: OrderByDirection.DescNullsFirst,
        },
      ],
    },
  })

  const [insertTask, { loading: mutationLoading }] = useMutation(taskInsert)

  const [updateTask, { loading: updateLoading }] = useMutation(taskUpdate)

  const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    const formElement = event.currentTarget
    event.preventDefault()
    const { title } = Object.fromEntries(new FormData(event.currentTarget))
    if (typeof title !== 'string') return
    if (!title) return
    await insertTask({
      variables: {
        objects: [{ title }],
      },
      onCompleted: () => refetchTasks(),
    })
    formElement.reset()
  }

  const toggleTaskStatus = async (taskId: string, updatedStatus: boolean) => {
    await updateTask({
      variables: {
        set: {
          is_completed: updatedStatus,
        },
        filter: {
          id: {
            eq: taskId,
          },
        },
      },
      onCompleted: () => refetchTasks(),
    })
  }

  if (loading) {
    return <div>Loading</div>
  }

  const tasks = queryData!.tasksCollection!.edges
  return (
    <div className="h-full flex flex-col">
      <div className="flex-grow min-h-0 overflow-y-auto">
        {tasks.map((task) => (
          <div key={task.node.id} className="text-lg p-1 flex">
            <div className="flex-grow">{task.node.title}</div>
            <button
              className="px-2"
              onClick={() =>
                toggleTaskStatus(task.node.id, !task.node.is_completed)
              }
            >
              <svg
                xmlns="http://www.w3.org/2000/svg"
                fill="none"
                viewBox="0 0 24 24"
                strokeWidth={1.5}
                stroke="currentColor"
                className={`w-6 h-6 ${
                  task.node.is_completed
                    ? 'stroke-green-500'
                    : 'stroke-gray-500'
                }`}
              >
                <path
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
                />
              </svg>
            </button>
          </div>
        ))}
      </div>
      <form className="flex items-center p-1" onSubmit={onSubmit}>
        <input
          className="border-green-300 border bg-transparent rounded py-1 px-2 flex-grow mr-2"
          type="title"
          name="title"
          placeholder="New Task"
        />
        <button
          type="submit"
          disabled={mutationLoading}
          className="py-1 px-4 text-lg bg-green-400 rounded text-black disabled:bg-gray-500"
        >
          {mutationLoading ? 'Saving...' : 'Save'}
        </button>
      </form>
    </div>
  )
}

const LoginForm = () => {
  const sendMagicLink = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    const { email } = Object.fromEntries(new FormData(event.currentTarget))
    if (typeof email !== 'string') return

    const { error } = await supabase.auth.signInWithOtp({ email })
    if (error) {
      console.log(error)
      alert(error.message)
    } else {
      alert('Check your email inbox')
    }
  }

  return (
    <form
      className="flex flex-col justify-center space-y-2 max-w-md px-4 h-full"
      onSubmit={sendMagicLink}
    >
      <input
        className="border-green-300 border rounded p-2 bg-transparent text-white"
        type="email"
        name="email"
        placeholder="Email"
      />
      <button
        type="submit"
        className="py-1 px-4 text-lg bg-green-400 rounded text-black"
      >
        Send Magic Link
      </button>
    </form>
  )
}

/** 上の`Home`をスッキリさせるためだけに抜き出したヘッダー */
const AppHeader = ({ isSignedIn }: { isSignedIn: boolean }) => {
  console.log({ isSignedIn })
  return (
    <header className="bg-black shadow shadow-green-400 px-4">
      <div className="flex max-w-4xl mx-auto items-center h-16">
        <div className=" text-white text-lg flex-grow">
          Supabase pg_graphql Example
        </div>
        {isSignedIn && (
          <button
            className="py-1 px-2 text-white border border-white rounded"
            onClick={() => supabase.auth.signOut()}
          >
            Sign Out
          </button>
        )}
      </div>
    </header>
  )
}

如果到这一步,应该可以通过npm run dev来实际运行应用程序!另外,我已经将我自己创建的版本放在了Vercel上,所以如果你觉得”自己全部创建实际上很麻烦”,可以从这里看看。

结合

你觉得怎么样?就我个人而言,因为之前从未真正使用过GraphQL来构建应用,所以有一些困难,但最终还是做得挺不错的,我感到很满意。我希望GraphQL的支持者也能尝试使用Supabase!如果对pg_graphql有任何功能请求,请务必留下评论!

相关链接

    • 今回のアプリのソースコード

 

    • 実際に動いてるアプリ

 

    • Supabase pg_graphql公式ドキュメンテーション

 

    • pg_graphqlリリースブログ記事

 

    pg_graphql v1.0発表ブログ記事
广告
将在 10 秒后关闭
bannerAds