shimotsu tech

Webフロントエンドエンジニア @ to-R inc.

Supabase のデータ取得の際に、任意のプロパティで複数の条件に合致するレコードを取得したい

Supabase の DB からデータを取得する際に、少し凝ったフィルタリングの実装に悩んだのでメモがてら書いておきます。

通常

supabase.from() メソッドでデータを取得する際、通常は以下のように eq() メソッドを繋ぎ、条件を指定します。この例では、posts テーブルから、 user_id が合致するレコードを取得しています。

const { data: posts } = await supabase
    .from("posts")
    .select('*')
    .eq('user_id', userId) // user_id の値が合致するレコードを抽出

任意のプロパティで、複数の条件に合致するレコードを取得したいケース

今回少し悩んだのが、任意のプロパティで、複数の条件に合致するレコードを取得したいケースです。

例えば、posts テーブルに is_published(公開されているか否か) というカラムがあると仮定して、is_published の値が true なレコードのみを抽出したい場合は以下のようにすればOKです。これは単純です。

const { data: posts } = await supabase
    .from("posts")
    .select('*')
    .eq('is_published', "true")

次に、URLのクエリで ?tab=all?tab=public?tab=private のようにフィルタリングをかける例を考えてみます。以下のようなイメージです。

  1. ?tab=all : 全件取得(is_publishedの値がtrueかつfalse` なレコードを取得)
  2. ?tab=public : is_published の値が true なレコードのみ取得
  3. ?tab=private : is_published の値が false なレコードのみ集約

2と3は上記の例のとおりに書けば取得できます。こんな感じで、クエリの値を見て条件を絞ればいいでしょう。

const specificValueFromQuery = () => {
    if (query.tab === 'public') return 'true'
    if (query.tab === 'private') return 'false'
  }

const { data: posts } = await supabase
    .from("posts")
    .select('*')
    .eq('is_published', specificValueFromQuery())

しかし、問題となるのが1のパターンです。?tab=all の場合は全件取得したいですが、eq() メソッドで絞り込んでいる以上、 is_publishedtrue または false のレコードを取得することはできません。

Supabase のjavascript クライアントでの型定義を見ても、eq() メソッドの第二引数は一意の値でないといけなさそうです(true | false とかで指定できるとベターだった)。

/**
   * Finds all rows whose value on the stated `column` exactly matches the
   * specified `value`.
   *
   * @param column  The column to filter on.
   * @param value  The value to filter with.
   */
  eq(column: keyof T, value: T[keyof T]): this {
    this.url.searchParams.append(`${column}`, `eq.${value}`)
    return this
  }

この場合、そもそも .eq() メソッドが不要なので外したいが、ちょっとそれは難しそう。 でも、このために2回以上APIを叩くのは無駄だし、どうしたものか...。

解決策

苦肉の策ですが、全件取得した後のデータに対して、query の値を条件にフィルタリングをかけ、新しい配列を生成するようにしました。

const { data: rowPosts } = await supabase
    .from("posts")
    .select('*')
    .eq('user_id', id)

  const posts = rowPosts?.filter((post) => {
    if (!query.tab) return post

    if (query.tab === 'public') return post.is_published
    if (query.tab === 'private') return !post.is_published
  })

Supabase 側の実装では実現できそうになかったので、苦し紛れの方法になってしまいましたが、なんとか条件を満たすフィルタリングができるようになりました。

react-hook-form のフォームに zod でバリデーションを実装する

React 用のフォームバリデーションライブラリ react-hook-form で、zod を使ったバリデーションの実装を行ったのでメモしておきます。

react-hook-form.com

github.com

zod は、型と値を検証できる軽量なバリデーションライブラリで、react-hook-form にはそれ専用のリゾルバが用意されています。

まず、z.object({}) メソッドを用いて、オブジェクトスキーマを作成します。

const schema = z.object({
  title: z.string().nonempty(), // プロパティ `title` は、string型かつ空欄ではない
});

次に、useForm() を呼び出す際に、resolver プロパティを以下のように設定します。(※バリデーション以外の設定も含んでいます)

const {
    register,
    handleSubmit,
    formState: { isValid, isDirty },
  } = useForm<Inputs>({
    resolver: zodResolver(schema), // 上記のスキーマを用いて、リゾルバを設定
    defaultValues: {
      title: '',
    },
    mode: "onChange"
  })

最後に、useForm() で得た値を用いて通常通りフォームを実装します。

const Form: React.FC<props> = ({ onSubmit }) => {
  const {
    register,
    handleSubmit,
    formState: { isValid, isDirty },
  } = useForm<Inputs>({
    resolver: zodResolver(schema),
    defaultValues: {
      title: '',
    },
    mode: "onChange"
  })

  return (
    <form onSubmit={handleSubmit(onSubmit)} className={styles.form}>
      <label>タイトル</label>
      <input
        {...register('title')}
        className={styles.input}
      />
      <div className={styles.buttonWrapper}>
        <CustomButton type="submit" title="登録" disabled={!isDirty || !isValid} />
      </div>
    </form>
  )
}

export default Form

これで、formState 内の isValid の値は zod を介した検証に基づいて返されるようになりました。

この例でいうと、CustomButtondisabled プロパティは、リゾルバによる検証をクリアしているときのみ、 false となります。

Scaffdog で React コンポーネントに必要なファイルを自動生成する

React でコンポーネントを作る際(css modules を使う想定)、基本的には以下の2つのファイルを毎回作っています。

▼ Component/index.tsx

import React from 'react'

import styles from './index.module.scss'

const Component: React.FC = () => {
  return (
    <div className={styles.someStyle}>
      
    </div>
  )
}

export default Component

▼ Component/index.module.scss

.someStyle {
    display: block; // 例
}

この雛形をざっくり作ったり、他のコンポーネントからコピペしてきたりしてから実装を始めているのですが、毎回この作業をやるのが億劫になっており、「なんとかならないかな...」と思っていました。

Scaffdog を使う

そんなとき、このような悩みをズバリ解決するツール「Scafdog」を発見しました。

名前の通りいわゆるスキャフォールディングのためのツールです。Ruby on Rails を勉強したときに、 "scaffold" という機能について知ったのですが、まさにそのイメージでしょうか。

github.com

早速使ってみます。

まず、プロジェクトにインストールします。

yarn add -D scaffdog

次に、プロジェクトのルートに .scaffdog フォルダを作成し、config.js を作成します。

config.js は必須で、これから指定するどのテンプレートファイルを読み込むかを指定します。今回はフォルダのルートにテンプレートファイルを投げ込んでいく想定なので、以下のようにしています。

詳しくはこちら

▼ config.js

module.exports = {
  files: ['./*'],
}

次に、コマンドによってどのようなファイルを生成するかを決めるテンプレート(マークダウンファイル)を定義します。

ここでは、冒頭に記載した雛形に似たテンプレートを定義しました。(※注: マークダウンファイル内にエスケープ文字 \ を入れています。)

▼ component.md

---
name: 'component'
message: 'Enter Title of your component (no space)'
root: '.'
output: './src/components'
ignore: []
questions:
  name: "Please enter component name.(Convert to Pascal case.)"
---

# {{ inputs.name | pascal }}/index.tsx

\```markdown
import React from 'react'

import styles from './index.module.scss'

type props = {}

const {{ inputs.name }}: React.FC<props> = ({}) => {
  return (
    <div className={styles.wrapper}>

    </div>
  )
}

export default {{ inputs.name }}
\```

<!-- markdownlint-disable -->
# {{ inputs.name | pascal }}/index.module.scss

\```markdown
.wrapper {

}

\```

ここまで完了すれば、設定はOKです。

あとは、ターミナルで scaffdog generate component を実行すれば、出力先フォルダとコンポーネント名(テンプレート内で inputs.name で参照できる)を聞かれるので、それに答えると、無事ファイルが出力されます。

よく使う場合は、npmスクリプトを定義してもいいかもしれません。

./src/components/CustomButton を生成する例 f:id:zuboriradio:20220121192352j:plain

これで毎回手書きでコンポーネントの雛形を作らなくてよくなった!

Observser Pattern を カスタムフックで実装する

最近、JavaScript(と、主にReact)のデザインパターンをまとめている ebook をちょこちょこ読み進めています。

www.patterns.dev

この中で、初めて見たデザインパターンとして Observser Pattern (オブザーバパターン)というパターンが出てきたのですが、ここでの書き方が ES2015 以降のクラスを用いた書き方だったので、勉強がてら React のカスタムフックを使った実装に書き換えてみます。

元の実装で Observable.js 内で書かれていたものを、 useObserver フックとして定義しました。

Observable.js で、メンバ変数として定義されていた observers を、ここではステートとして管理しています。その他はすべてシンプルな関数です。

// useObserver.js

import { useState } from "react";

export const useObserver = () => {
  const [observers, setObservers] = useState([]);

  const subscribe = (f) => {
    setObservers(f);
  };

  const unsubscribe = (f) => {
    setObservers(observers.filter((o) => o !== f));
  };

  const notify = (data) => {
    observers.forEach((observer) => observer(data));
  };

  return {
    observers,
    subscribe,
    unsubscribe,
    notify
  };
};

使う側である App.js は以下のようになります。

// App.js

import "./styles.css";
import { Button, Switch, FormControlLabel } from "@material-ui/core";
import { ToastContainer, toast } from "react-toastify";
import { useObserver } from "./useObserver.js";
import { useEffect } from "react";

export default function App() {
  const { subscribe, notify } = useObserver();

  useEffect(() => {
    subscribe([toastify, logger]);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  function handleClick() {
    notify("User clicked button!");
  }

  function handleToggle() {
    notify("User toggled switch!");
  }

  function logger(data) {
    console.log(`${Date.now()} ${data}`);
  }

  function toastify(data) {
    toast(data, {
      position: toast.POSITION.BOTTOM_RIGHT,
      closeButton: false,
      autoClose: 2000
    });
  }

  return (
    <div className="App">
      <Button variant="contained" onClick={handleClick}>
        Click me!
      </Button>
      <FormControlLabel
        control={<Switch name="" onChange={handleToggle} />}
        label="Toggle me!"
      />
      <ToastContainer />
    </div>
  );
}

Next.js の特定のディレクトリ下において指定したCookieの有無で閲覧制御をする

例えばアプリケーションのログインページおよび新規登録ページが、それぞれ /auth/login/auth/signup に配置されていると仮定します。

- pages
    - auth
        - login.tsx
        - signup.tsx

このとき、これらのページにアクセスできるのは未ログイン状態のユーザーのみで、ログイン済のユーザーにはアクセスさせたくない(トップページにリダイレクトさせるなど)という場面はあるかと思います。

一般的には、ページコンポーネントの getServerSideProps 内や、クライアント側で Cookie の有無を判定したりするかと思いますが、Next.js の Middleware 機能が有効活用できます。

Middleware は、どこに配置するかによって実行順序を指定できるようになっており、 この場合、 auth ディレクトリ配下に _middleware.ts を置くことで、auth ディレクトリ配下のページにおいて優先的に Middleware が実行されるようになります。

- pages
    _middleware.ts // ルートのミドルウェア
    - auth
        - login.tsx
        - signup.tsx
        _middleware.ts // auth用のルートのミドルウェア

nextjs.org

_middleware.ts 内では、指定の Cookie の値(このケースでは sb:token)を評価し、truthy な場合(つまり、ログイン状態である場合)はトップページにリダイレクトさせています。

逆に、falthy な場合は NextResponse.next() としてそのまま表示させています。

import { NextRequest, NextResponse } from 'next/server'

export function middleware(req: NextRequest) {
  const sbCookie = req.cookies['sb:token'] // Cookie 内の任意の値

  if (sbCookie) {
    return NextResponse.redirect('/')
  }

  return NextResponse.next()
}

『Reactハンズオンラーニング』を読んだ

『Reactハンズオンラーニング』を一周し終えたので、ざっくり感想をまとめます。

内容についてと、全体的な感想

「ハンズオンラーニング」と銘打っているだけあって、なにかターゲットとなる目標物をじっくり解説(Reactの)を交えて作り上げていくような本です。

フックを使った React の書き方の基礎を網羅しているといったイメージで、業務等で React を普段書いているような人からしてみれば「もう知ってるよ」と思うような内容が多いかもしれません。

個人的に勉強になった箇所

本書では React の導入に入る前に関数型プログラミング(および宣言型プログラミング)」について解説している項があるのですが、それが大変勉強になりました。

そもそも「命令形」と「宣言型」のプログラミングのスタイルの違いについてあまり理解していなかったのですが、この違いについて十分にページを割いて解説してくれているので、大まかな違いは理解できました。

実際にこれまで自分が書いていた React のコードを思い返してみると、見事に「宣言型」のプログラミングスタイルに則っており、「あぁ、まさにこれか!」と。インプットと過去の経験を照らし合わせて、パズルのピースが完成するような感覚で、これは気持ちよかったです。

関数型プログラミング(および宣言型プログラミング)の基礎に立ち返りたいとき、おそらくここは何度も読み返すことになるだろうなと思います。

amzn.to

props の値を評価して表示するかどうか決定する <Maybe> コンポーネント

next-realworld-example-app」のコードを読み込んでいて参考になる箇所があったのでメモしておきます。

ごくシンプルなユーティリティ系のコンポーネント <Maybe> です。 props に表示の可否を決定するための値 test を渡し、その値の如何によって表示を制御します。

import React from "react";

const Maybe = ({ test, children }) => <>{test && children}</>;

export default Maybe;

使い方

以下のように使います。

<Maybe test={isUser}>
    <CustomLink
      href="/user/settings"
      as="/user/settings"
      className="btn btn-sm btn-outline-secondary action-btn"
    >
      <i className="ion-gear-a" /> Edit Profile Settings
    </CustomLink>
  </Maybe>

isUser が true な場合、子コンポーネントである <CustomLink /> が表示されます。

これまで、同様の実装をする場合は、素直に以下のように実装していましたが、この書き方もアリかなと。

{isUser && (
    <CustomLink
      href="/user/settings"
      as="/user/settings"
      className="btn btn-sm btn-outline-secondary action-btn"
    >
      <i className="ion-gear-a" /> Edit Profile Settings
    </CustomLink>
)}