Rista Tech Blog

株式会社リスタの技術?ブログ

Rails 6.1のDelegated Typesでgraphql-rubyのUnionを実装してみる

Next.js楽しいよ(*´Д`)ハァハァ

最近、JOBLISTのRailsバージョンを6.1にアップデートしたので、今日はRails 6.1で新しく導入されたDelegated Typesを触ってみます。

railsguides.jp

Delegated TypesはSTI(単一テーブル継承。今日はSTIの説明はしません)と同様の機能を提供するもので、単一テーブルではなく複数テーブルで動作します。
※ リスタではSTIは使わないようにしていて、同様のことをやりたい場合は自前で複数テーブルを作って対応しています。

最近、個人的にGraphQLを検証しているので、GraphQLのUnionを実装するのに使ってみました。
やってみようと思ったら同じようなことをやっている人が既にいたので、今回の記事はこちらの記事を参考にしつつ自分なりに調整してみた、という記事になります。

qiita.com

環境セットアップ

rails初期化

% rails -v
Rails 6.1.3
rails new delegate_type_union

cd delegate_type_union
rails db:create

graphqlの初期化

echo 'gem "graphql"' >> Gemfile
bundle
rails g graphql:install

config/initializers/assets.rbに以下を追記する

Rails.application.config.assets.precompile += %w[graphiql/rails/application.js graphiql/rails/application.css]

rails sして http://localhost:3000/graphiql にアクセスし、GraphiQLのUIが表示されるのを確認

f:id:mikeda:20210221174301p:plain

モデル定義

以下の3つのテーブルを作成します。

  • characters(親)
    • name:string
  • human(子)
    • height:integer
  • droid(子)
    • weight:float

という表現はあまり適切じゃない気が🤔

Model作成

rails g model character name:string characterable_id:bigint characterable_type:string
rails g model human height:integer
rails g model droid weight:float
class CreateCharacters < ActiveRecord::Migration[6.1]
  def change
    create_table :characters do |t|
      t.string :name, null: false

      # 複数テーブルのIDが入ってくるので外部キー制約はつけられない
      t.bigint :characterable_id, null: false, index: true

      # `Human`, `Droid`のように子クラスのクラス名が入ってくる
      t.string :characterable_type, null: false

      t.timestamps

      t.index %i[characterable_type characterable_id], unique: true
    end
  end
end
class CreateHumen < ActiveRecord::Migration[6.1]
  def change
    create_table :humen do |t|
      t.integer :height, null: false

      t.timestamps
    end
  end
end
class CreateDroids < ActiveRecord::Migration[6.1]
  def change
    create_table :droids do |t|
      t.float :weight, null: false

      t.timestamps
    end
  end
end

rails db:migrateでmigrate実行。

モデルのコード

親テーブル

class Character < ApplicationRecord
  delegated_type :characterable, types: %w[Human Droid], dependent: :destroy
end

delegated_typeで関連付けの名前、子テーブルのクラスを指定します。

小テーブル

class Human < ApplicationRecord
  include Characterable
end
class Droid < ApplicationRecord
  include Characterable
end

小テーブルの共通処理はConcernに。

module Characterable
  extend ActiveSupport::Concern

  included do
    has_one :character, as: :characterable, touch: true, dependent: :destroy
  end
end

サンプルデータ作成と動作確認

human = Character.create!(
  name: "mikeda",
  characterable: Human.new(height: 168)
)

droid = Character.create!(
  name: "ドラえもん",
  characterable: Droid.new(weight: 129.3)
)

動作を確認

> human
=> #<Character id: 1, name: "mikeda", characterable_id: 1, characterable_type: "Human", created_at: "2021-02-21 05:25:45.688717000 +0000", updated_at: "2021-02-21 05:25:45.688717000 +0000">

> droid
=> #<Character id: 2, name: "ドラえもん", characterable_id: 1, characterable_type: "Droid", created_at: "2021-02-21 05:26:23.852066000 +0000", updated_at: "2021-02-21 05:26:23.852066000 +0000">

> human.human?
=> true

> human.characterable_name
=> "human"

> human.characterable_type
=> "Human"

> human.characterable
=> #<Human id: 1, height: 168, created_at: "2021-02-21 05:25:45.679562000 +0000", updated_at: "2021-02-21 05:25:45.679562000 +0000">

> Character.humen
=> #<ActiveRecord::Relation [#<Character id: 1, name: "mikeda", characterable_id: 1, characterable_type: "Human", created_at: "2021-02-21 05:25:45.688717000 +0000", updated_at: "2021-02-21 05:25:45.688717000 +0000">]>

> Character.droids
=> #<ActiveRecord::Relation [#<Character id: 2, name: "ドラえもん", characterable_id: 1, characterable_type: "Droid", created_at: "2021-02-21 05:26:23.852066000 +0000", updated_at: "2021-02-21 05:26:23.852066000 +0000">]>

GraphQLのクエリ定義

雑なクエリを定義しておく(CharacterTypeはまだないので動かない)

module Types
  class QueryType < Types::BaseObject
    field :characters, [Types::CharacterType], null: false

    def characters
      Character.all
    end
  end
end

テーブル構造そのまま

まずはテーブル構造そのままでデータを取得できるようにします。

{
  characters {
    id
    name
    characterable {
      __typename
      ... on Human {
        height
      }
      ... on Droid {
        weight
      }
    }
  }
}

想定レスポンス

{
  "data": {
    "characters": [
      {
        "id": "1",
        "name": "mikeda",
        "characterable": {
          "__typename": "Human",
          "height": 168
        }
      },
      {
        "id": "2",
        "name": "ドラえもん",
        "characterable": {
          "__typename": "Droid",
          "weight": 129.3
        }
      }
    ]
  }
}

各モデルの型定義

module Types
  class CharacterType < BaseObject
    field :id, ID, null: false
    field :name, String, null: false
    field :characterable, CharacterableType, null: false
  end
end
module Types
  class HumanType < BaseObject
    field :height, Int, null: false
  end
end
module Types
  class DroidType < BaseObject
    field :weight, Float, null: false
  end
end

Unionの定義

module Types
  class CharacterableType < BaseUnion
    possible_types HumanType, DroidType

    def self.resolve_type(object, context)
      case object
      when Human
        HumanType
      when Droid
        DroidType
      end
    end
  end
end

クエリ内のcharacterableを消す

クエリ内のcharacterableを消してクエリの階層を浅くしてみます。

{
  characters {
    __typename
    ... on Human {
      id
      name
      height
    }
    ... on Droid {
      id
      name
      weight
    }
  }
}

想定レスポンス

{
  "data": {
    "characters": [
      {
        "__typename": "Human",
        "id": "1",
        "name": "mikeda",
        "height": 168
      },
      {
        "__typename": "Droid",
        "id": "2",
        "name": "ドラえもん",
        "weight": 129.3
      }
    ]
  }
}

モデル定義調整

ちょっと乱暴ですが、Characterから子クラスに必要なメソッドをdelegateします。

class Character < ApplicationRecord
  delegated_type :characterable, types: %w[Human Droid], dependent: :destroy
  # delegate :height, :weight, to: :characterable
  delegate :height, to: :human
  delegate :weight, to: :droid
end

各モデルの型定義

共通部分をCharacterableTypeとして定義し、HumanType、DroidTypeはそれを継承するようにします。

module Types
  class CharacterableType < BaseObject
    field :id, ID, null: false
    field :name, String, null: false
  end
end
module Types
  class HumanType < CharacterableType
    field :height, Int, null: false
  end
en
module Types
  class DroidType < CharacterableType
    field :weight, Float, null: false
  end
end

CharacterTypeをUnionに変更する

Unionの中で扱うオブジェクトを子クラスからCharacterに変更しています。

module Types
  class CharacterType < BaseUnion
    possible_types HumanType, DroidType

    def self.resolve_type(object, context)
      if object.human?
        HumanType
      elsif object.droid?
        DroidType
      end
    end
  end
end

InterfaceとFragmentで共通部分をまとめる

Interfaceを定義して共通部分をFragmentとしてまとめられるようにしてみます。

fragment character on Characterable {
  id
  name
}

{
  characters {
    __typename
    ...character
    ... on Human {
      height
    }
    ... on Droid {
      weight
    }
  }
}

Interface定義

module Types
  module Characterable
    include BaseInterface

    field :id, ID, null: false
    field :name, String, null: false
  end
end

HumanType, DroidTypeでInterfaceをimplements

module Types
  class HumanType < BaseObject
    implements Characterable

    field :height, Int, null: false
  end
end
module Types
  class DroidType < BaseObject
    implements Characterable

    field :weight, Float, null: false
  end
end

※ GraphiQL上ではちゃんと動いてるように見えるんですが、graphql-codegenで生成したhookからinterface定義した値にアクセスできなくて調査中🤔

まとめ

Rails 6.1のDelegated Typesを使って、graphql-rubyのUnionを実装してみました。

Delegated Typesについては、親テーブルの単一カラムに全ての子テーブルの参照用IDが入っている点に、最初ちょっと違和感を感じました。外部キー制約がつけられないため、データの不整合が怖いなと。

それ以外については特にわかりづらい点はなく、シンプルな機能であまりハマりポイントもなさそうなので、既存機能をわざわざ置き換えていくまではしないですが、新しく作る機能については積極的に使ってみようかなと思いました。