Next.js楽しいよ(*´Д`)ハァハァ
最近、JOBLISTのRailsバージョンを6.1にアップデートしたので、今日はRails 6.1で新しく導入されたDelegated Typesを触ってみます。
Delegated TypesはSTI(単一テーブル継承。今日はSTIの説明はしません)と同様の機能を提供するもので、単一テーブルではなく複数テーブルで動作します。
※ リスタではSTIは使わないようにしていて、同様のことをやりたい場合は自前で複数テーブルを作って対応しています。
最近、個人的にGraphQLを検証しているので、GraphQLのUnionを実装するのに使ってみました。
やってみようと思ったら同じようなことをやっている人が既にいたので、今回の記事はこちらの記事を参考にしつつ自分なりに調整してみた、という記事になります。
環境セットアップ
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が表示されるのを確認
モデル定義
以下の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が入っている点に、最初ちょっと違和感を感じました。外部キー制約がつけられないため、データの不整合が怖いなと。
それ以外については特にわかりづらい点はなく、シンプルな機能であまりハマりポイントもなさそうなので、既存機能をわざわざ置き換えていくまではしないですが、新しく作る機能については積極的に使ってみようかなと思いました。