🧩

Hono × Supabase 複数テーブルを1回のクエリでレスポンス!

に公開

はじめに

Supabase SDKと軽量WebフレームワークHonoを組み合わせてAPIを作る中で、「複数テーブルをJOINして1回のクエリでレスポンスしたい」というニーズにぶつかりました。
この記事では、Supabaseの .select() におけるJOINの使い方と、RelationalRepository を活用して再利用性高く設計する方法を紹介します。

Supabaseの .select() でJOINする方法

Supabaseでは、RDBの外部キーを活用して .select()ネスト構文を渡すことでJOINを実現できます。

const { data, error } = await supabase
  .from('staffs')
  .select('id, name, place(id, name)');

このように書くと、staffs テーブルに関連する place のデータも同時に取得できます。
裏側ではPostgRESTが外部キーを解釈して LEFT JOIN を実行してくれます。

実践

前提 : TableRepository との関係

本記事の内容は、既に構築済みの TableRepository を前提にしています。
これは、単一テーブルに対するCRUD(取得・更新・削除など)操作を共通化したベースクラスです。

abstract class TableRepository<T extends BaseEntity> {
  protected abstract readonly TABLE_NAME: string;
  protected abstract readonly ID_FIELD: string;
  protected abstract readonly UPDATED_AT_FIELD: string;

  constructor(protected readonly client: SupabaseClient) {}

  // 共通の取得・更新・削除メソッドを提供
}

RelationalRepository を作って共通化

今回紹介する RelationalRepository は上記の TableRepository を継承して、JOINを含むクエリ構文を扱えるようにした拡張版です。

export abstract class RelationalRepository<T extends BaseEntity> extends TableRepository<T> {
  protected abstract readonly BASE_TABLE: string;
  protected abstract readonly SELECT_QUERY: string; // e.g., '*, place(name)'
  protected abstract readonly ID_FIELD: string;
  protected readonly UPDATED_AT_FIELD: string = 'updated_at';

  async getByIdentifier(identifier: string): Promise<T> {
    this.validateIdentifier();
    this.validateField(identifier);
    const query = this.client
      .from(this.BASE_TABLE)
      .select(this.SELECT_QUERY)
      .eq(this.IDENTIFIER_FIELD!, identifier)
      .single();
    return this.executeQuery<T>(query, 'getByIdentifier');
  }
}

使用例:StaffWithPlaceRepository

export class StaffWithPlaceRepository extends RelationalRepository<StaffWithPlace> {
  protected readonly BASE_TABLE = StaffFields.TABLE_NAME;
  protected readonly SELECT_QUERY = `*, ${PlaceFields.TABLE_NAME}(*)`;
  protected readonly TABLE_NAME = StaffFields.TABLE_NAME;
  protected readonly ID_FIELD = StaffFields.ID;
  protected readonly IDENTIFIER_FIELD = StaffFields.STAFF_IDENTIFIER;
  protected readonly UPDATED_AT_FIELD = StaffFields.UPDATED_AT;

  /// [GET] 
  async getStaffWithPlaceByIdentifier(staff_identifier: string): Promise<StaffWithPlace> {
    try {
      return await this.getByIdentifier(staff_identifier);
    } catch (e) {
      console.error('getUserBySupabaseId エラー:', e);
      throw e;
    }
  }

HonoでAPIを組み合わせるとこうなる

export const getStaff = async (
  { staff_identifier }: { staff_identifier: string },
  c: any
) => {
  try {
    const supabase = createSupabaseClient(c);
    const staffWithPlaceRepo = new StaffWithPlaceRepository(supabase);
    const staffWithPlaceData = await staffWithPlaceRepo.getStaffWithPlaceByIdentifier(staff_identifier);

    return {
      status: 'fetched',
      message: 'スタッフ情報を取得しました',
      data: staffWithPlaceData,
    };
  } catch (e) {
    console.error('エラー発生:', e);
    throw e;
  }
};

因みに変更前の同じ関数はこんな感じです!
よりシンプルになったことが分かるかと思います。

export const getStaff = async (
  { staff_identifier }: { staff_identifier: string },
  c: any
) => {
  try {
    const supabase = createSupabaseClient(c);
    const staffRepo = new StaffRepository(supabase);
    const staffData = await staffRepo.getStaffByIdentifier(staff_identifier);

    const placeRepo = new PlaceRepository(supabase);
    const placeData = await placeRepo.getPlaceById(staffData.place_id);
    return {
      status: 'fetched',
      message: 'スタッフ情報を取得しました',
      data: {
        ...staffData,
        place: placeData,
      },
    };
  } catch (e) {
    console.error('エラー発生:', e);
    throw e;
  }
};

まとめ

複数回のリクエストを Supabase に投げるコードから脱却することで、パフォーマンスの向上とコードの見通しの良さを両方得ることができてスッキリしました!
うれしい!

おまけ:ネストJOINの応用

.select('id, gift(name), user(id, name), staff(id, name, place(id, name))')

このように、2段階、3段階のネストJOINも対応可能です。
SupabaseはPostgreSQLの力を使ってレスポンスを生成しているので、正しくリレーションが貼られていれば強力なJOINが使えます。

ぽちぽちのつどい

Discussion