27
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Rails6.1で追加されたdelegated typeとGraphQLのUnion typesは相性よさそうなので試したみた

Posted at

VISITS Technologiesでマネージャー兼バックエンドエンジニアをしている@ham0215です。

今年のアドベントカレンダー3つ目の記事です。
直前までどのような内容の記事を書こうか迷っていたのですが、直前で @woods0918 さんがGraphQLの記事を投稿したので、私も流れに乗ってGraphQLの記事にしてみました。

Rails6.1で追加されたdelegated typeとGraphQLのUnion typesは相性よさそうだなと思ったので、実際に触ってみて使い心地を確かめてみました。

GraphQLのUnion typesとは

GraphQLにはUnion typesというタイプがあります。
公式ホームページの下記に記載されています。
https://graphql.org/learn/schema/#union-types

Union typesを使うと複数のタイプを組み合わせたタイプを表現することができます。
この記事では、公式ホームページのサンプルをそのまま使わせていただきます。

{
  search(text: "an") {
    __typename
    ... on Human {
      name
      height
    }
    ... on Droid {
      name
      primaryFunction
    }
    ... on Starship {
      name
      length
    }
  }
}

searchクエリーを実行するとHumanDroidStarshipという3種類のタイプが混在したリストが返却されます。
name属性は全タイプ共通ですが、Humanの場合はheightDroidの場合はprimaryFunction、Starshipの場合はlengthという独自の属性を持っています。

実行結果は下記のようになります。
text: 'an'というパラメーターがついているのでnameに'an'が含まれているデータを検索しています。
dataの中にHumanとStarshipのデータが混在しています(Droidは'an'がつくデータがなかったようですね)

{
  "data": {
    "search": [
      {
        "__typename": "Human",
        "name": "Han Solo",
        "height": 1.8
      },
      {
        "__typename": "Human",
        "name": "Leia Organa",
        "height": 1.5
      },
      {
        "__typename": "Starship",
        "name": "TIE Advanced x1",
        "length": 9.2
      }
    ]
  }
}

delegated typeとは

下記のプルリクエストで実装された機能です。
DHHが自らプルリク出していて、Rails6.1に入ったようです。
https://github.com/rails/rails/pull/39341

内容はプルリクの説明にサンプルコードも貼ってあるので、それを読む方が理解しやすいと思いますが、一言でいうとテーブルの継承関係を表現しやすくなる機能です。
...と言ってもイメージが湧きづらいと思うので、Union typesと同じ例を使って説明します。
この例のデータを保持するには1テーブルで表現する方法と複数テーブルで表現する方法が考えられます。
それぞれのどのようなテーブル構造になるのか確認しながら説明します。

1テーブルで表現する

1テーブルで表現する場合、下記のようなテーブルになると思います。

Field Type Null
id bigint(20) NO
name varchar(255) NO
type tinyint(4) NO
height float YES
primary_function varchar(255) YES
length float YES

この構成の場合、タイプを横断して検索するときにもこのテーブルをリードすればよいだけなのでパフォーマンスが出しやすいです。
ただ、タイプごとの属性(height, primary_function, length)が任意項目になっているため、例えばHumanタイプの場合はheightカラムが必須で、その他はnullにするという制御をアプリケーション側で担保する必要があります。

複数テーブルで表現する

1テーブルで表現する場合、どうしてもRDB側の制約がゆるくなってしまいます。
これを回避するためにタイプごとにテーブルを作成するという方法があります。

  • Humans
Field Type Null
id bigint(20) NO
name varchar(255) NO
height float NO
  • Droids
Field Type Null
id bigint(20) NO
name varchar(255) NO
primary_function varchar(255) NO
  • Starships
Field Type Null
id bigint(20) NO
name varchar(255) NO
length float NO

1テーブルの場合と違い、タイプごとにテーブルが分かれているのでtypeカラムは不要になります。
また、タイプごとに必要な属性(height, primary_function, length)も必須カラムにすることができるので、RDBの制約で制御することができるようになります。

データを保存するという観点ではメリットが多いのですが、データを取得する時は3テーブルを参照する必要があります。
特に最初の例のように混在したデータを取得する場合はSQLのUNION句を使ったり、データは別々に取得してアプリケーション側の処理で結合したりする必要がありパフォーマンスが悪くなる可能性があります。

1テーブルと複数テーブルの良いとこ取り

「複数テーブルで表現する」で書いた方法だと、タイプが混在したデータを取得するときに苦労します。
これを解消させるためにもう1テーブル作ります。
char_typeと各テーブルへのidを持っているテーブルです。例えばchar_typeがHumanの場合は、char_idにhumans.idが入ります。
共通項目であるnameで検索することがあるので、こちらのテーブルにname属性を追加しました。
二重管理になってしまうので各テーブルからはnameを消します。

  • Characters
Field Type Null
id bigint(20) NO
name varchar(255) NO
char_type tinyint(4) NO
char_id bigint(20) NO
  • Humans
Field Type Null
id bigint(20) NO
height float NO
  • Droids
Field Type Null
id bigint(20) NO
primary_function varchar(255) NO
  • Starships
Field Type Null
id bigint(20) NO
length float NO

このように1階層上のテーブルを作ることで、混在したデータを取得する際はこのテーブルをリードすればできるようになります。
また、タイプごとの必須制約にRDBの制約を使うこともできています。

このようなテーブル構成のデータをRailsから簡単に扱えるようにした機能がdelegated typeです。

実装

それでは実装してみます。
実装する内容ははここまでに使ったGraphQL公式ページの例を使います。

バージョン

この記事を書いた時点の主要ライブラリのバージョンは下記の通り。

  • Ruby: 2.7.2
  • Rails: 6.1
  • graphql-ruby: 1.11.6
  • mysql: 5.7.27

migration

例に合わせてマイグレーションします。
面倒なので1ファイルにしましたが、きちんとやる場合はテーブルごとに分けたほうがいいと思います。
typeを格納するカラム(今回の例ではchar_type)には'Human'や'Droid’のように文字列が入るので、stringで定義します。

db/migrate/20201215031635_create_table_delegated_type.rb
class CreateTableDelegatedType < ActiveRecord::Migration[6.1]
  def change
    create_table :characters do |t|
      t.string :name, null: false
      t.string :char_type, null: false, limit: 10
      t.bigint :char_id, null: false

      t.timestamps
    end

    create_table :humans do |t|
      t.float :height, null: false

      t.timestamps
    end

    create_table :droids do |t|
      t.string :primary_function, null: false

      t.timestamps
    end

    create_table :starships do |t|
      t.float :length, null: false

      t.timestamps
    end
  end
end

下記のようにテーブルができました。

> desc characters;
+----------------+--------------+------+-----+---------+----------------+
| Field          | Type         | Null | Key | Default | Extra          |
+----------------+--------------+------+-----+---------+----------------+
| id             | bigint(20)   | NO   | PRI | NULL    | auto_increment |
| name           | varchar(255) | NO   |     | NULL    |                |
| char_type      | varchar(10)  | NO   |     | NULL    |                |
| char_id        | bigint(20)   | NO   |     | NULL    |                |
| created_at     | datetime(6)  | NO   |     | NULL    |                |
| updated_at     | datetime(6)  | NO   |     | NULL    |                |
+----------------+--------------+------+-----+---------+----------------+

> desc droids;
+------------------+--------------+------+-----+---------+----------------+
| Field            | Type         | Null | Key | Default | Extra          |
+------------------+--------------+------+-----+---------+----------------+
| id               | bigint(20)   | NO   | PRI | NULL    | auto_increment |
| primary_function | varchar(255) | NO   |     | NULL    |                |
| created_at       | datetime(6)  | NO   |     | NULL    |                |
| updated_at       | datetime(6)  | NO   |     | NULL    |                |
+------------------+--------------+------+-----+---------+----------------+

> desc humans;
+------------+-------------+------+-----+---------+----------------+
| Field      | Type        | Null | Key | Default | Extra          |
+------------+-------------+------+-----+---------+----------------+
| id         | bigint(20)  | NO   | PRI | NULL    | auto_increment |
| height     | float       | NO   |     | NULL    |                |
| created_at | datetime(6) | NO   |     | NULL    |                |
| updated_at | datetime(6) | NO   |     | NULL    |                |
+------------+-------------+------+-----+---------+----------------+

> desc starships;
+------------+-------------+------+-----+---------+----------------+
| Field      | Type        | Null | Key | Default | Extra          |
+------------+-------------+------+-----+---------+----------------+
| id         | bigint(20)  | NO   | PRI | NULL    | auto_increment |
| length     | float       | NO   |     | NULL    |                |
| created_at | datetime(6) | NO   |     | NULL    |                |
| updated_at | datetime(6) | NO   |     | NULL    |                |
+------------+-------------+------+-----+---------+----------------+

models

モデルを作成します。

まずは上位階層のCharacterモデルを定義します。
delegated_typeに必要な情報を設定します。
:char
各テーブルとのリレーションに使うxxx_type, xxx_idのxxxの部分を定義します。
今回はchar_typeとchar_idなので:charを設定しています。

types
対応するモデルを指定します。

dependent
charactersを削除したときに関連テーブルの挙動を定義します。アソシエーションの設定と同じです。

app/models/character.rb
class Character < ApplicationRecord
  delegated_type :char, types: %w[Human Droid Starship], dependent: :destroy
end

各typeのモデルがincludeするモジュールを作成します。
Characterモデルへのリレーション(has_one)と、共通項目(name)のdelegateを定義しました。

app/models/concerns/char.rb
module Char
  extend ActiveSupport::Concern

  included do
    has_one :character, as: :char, touch: true, dependent: :destroy
    delegate :name, to: :character
  end
end

各タイプに対応するモデルを作ります。
Charモジュールをincludeします。

app/models/human.rb
class Human < ApplicationRecord
  # humenテーブルを参照してしまうので
  self.table_name = "humans"
  include Char
end
app/models/droid.rb
class Doroid < ApplicationRecord
  include Char
end
app/models/starship.rb
class Starship < ApplicationRecord
  include Char
end

CRUD

一通りモデルが作成できたので、HumanへのCRUDを試してみました。

Create

humansとcharactersへinsertが行われます。整合性を保つため同一transactionで実行されるようです。
途中でcharactersへselectしていますが、これは重複チェックだと思います。

irb(main)> Character.create!(name: 'Han Solo', char: Human.new(height: 1.8))
  TRANSACTION (0.3ms)  BEGIN
  Human Create (0.4ms)  INSERT INTO `humans` (`height`, `created_at`, `updated_at`) VALUES (1.8, '2020-12-15 05:26:45.730884', '2020-12-15 05:26:45.730884')
  Character Load (0.5ms)  SELECT `characters`.* FROM `characters` WHERE `characters`.`char_id` = 1 AND `characters`.`char_type` = 'Human' LIMIT 1
  Character Create (0.3ms)  INSERT INTO `characters` (`name`, `char_type`, `char_id`, `created_at`, `updated_at`) VALUES ('Han Solo', 'Human', 1, '2020-12-15 05:26:45.833648', '2020-12-15 05:26:45.833648')
  TRANSACTION (1.8ms)  COMMIT
=> #<Character id: 1, name: "Han Solo", char_type: "Human", char_id: 1, created_at: "2020-12-15 05:26:45.833648000 +0000", updated_at: "2020-12-15 05:26:45.833648000 +0000">

Read

Characterモデルのオブジェクトから各タイプにアソシエーションと同じようにアクセスできるようになります。
違うタイプを指定したらnilが返ってきました。
また、human?のようなタイプを判定するメソッドも使えるようになります。

irb(main)> char = Character.first
  Character Load (0.9ms)  SELECT `characters`.* FROM `characters` ORDER BY `characters`.`id` ASC LIMIT 1
=> #<Character id: 1, name: "Han Solo", char_type: "Human", char_id: 1, created_at: "2020-12-15 05:26:45.833648000 +0000", updated_at: "2020-12-15 05:26:45.833648000 +0000">
irb(main)> char.human?
=> true
irb(main)> char.human
  Human Load (0.7ms)  SELECT `humans`.* FROM `humans` WHERE `humans`.`id` = 1 LIMIT 1
=> #<Human id: 1, height: 1.8, created_at: "2020-12-15 05:26:45.730884000 +0000", updated_at: "2020-12-15 05:26:45.730884000 +0000">
irb(main)> char.droid?
=> false
irb(main)> char.droid
=> nil
irb(main)> char.char_name
=> "human"

Update

Characterモデルの項目を修正するとcharactersテーブルのみ更新されます。

irb(main)> char.name = 'Han Soloooooo'
=> "Han Soloooooo"
irb(main)> char.save!
  TRANSACTION (0.5ms)  BEGIN
  Character Update (2.7ms)  UPDATE `characters` SET `characters`.`name` = 'Han Soloooooo', `characters`.`updated_at` = '2020-12-15 05:42:12.659673' WHERE `characters`.`id` = 1
  TRANSACTION (2.3ms)  COMMIT
=> true

Humanモデルの項目を修正するとcharactersテーブルのupdated_atも更新しています。
これはdelegated typeの機能というよりはchar moduleのhas_oneにtouch: trueを付けているからです。

irb(main)> char.human.height = 2.1
=> 2.1
irb(main)> char.human.save!
  TRANSACTION (0.3ms)  BEGIN
  Human Update (0.6ms)  UPDATE `humans` SET `humans`.`height` = 2.1, `humans`.`updated_at` = '2020-12-15 05:42:30.095070' WHERE `humans`.`id` = 1
  Character Load (0.9ms)  SELECT `characters`.* FROM `characters` WHERE `characters`.`char_id` = 1 AND `characters`.`char_type` = 'Human' LIMIT 1
  Character Update (0.5ms)  UPDATE `characters` SET `characters`.`updated_at` = '2020-12-15 05:42:30.103572' WHERE `characters`.`id` = 1
  TRANSACTION (2.5ms)  COMMIT
=> true

Delete

Characterモデルを削除しました。
dependent: :destroyを指定しているので関連するStarshipモデルも削除されています。

irb(main)> a = Character.last
  Character Load (0.5ms)  SELECT `characters`.* FROM `characters` ORDER BY `characters`.`id` DESC LIMIT 1
=> #<Character id: 3, name: "TIE Advanced x1", char_type: "Starship", char_id: 1, created_at: "2020-12-15 05:29:53.110094000 +0000", updated_at: "2020-12-15 05:29:53.110094000 +0000">
irb(main)> a.destroy
  TRANSACTION (0.3ms)  BEGIN
  Character Destroy (1.0ms)  DELETE FROM `characters` WHERE `characters`.`id` = 3
  Starship Load (0.5ms)  SELECT `starships`.* FROM `starships` WHERE `starships`.`id` = 1 LIMIT 1
  Character Load (1.0ms)  SELECT `characters`.* FROM `characters` WHERE `characters`.`char_id` = 1 AND `characters`.`char_type` = 'Starship' LIMIT 1
  Starship Destroy (0.9ms)  DELETE FROM `starships` WHERE `starships`.`id` = 1
  TRANSACTION (3.9ms)  COMMIT
=> #<Character id: 3, name: "TIE Advanced x1", char_type: "Starship", char_id: 1, created_at: "2020-12-15 05:29:53.110094000 +0000", updated_at: "2020-12-15 05:29:53.110094000 +0000">

Droidモデルを削除しました。
dependent: :destroyを指定しているので関連するCharacterモデルも削除されています。

irb(main)> b = Character.last
  Character Load (0.7ms)  SELECT `characters`.* FROM `characters` ORDER BY `characters`.`id` DESC LIMIT 1
=> #<Character id: 2, name: "C-3PO", char_type: "Droid", char_id: 1, created_at: "2020-12-15 05:29:45.752745000 +0000", updated_at: "2020-12-15 05:29:45.752745000 +0000">
irb(main)> b.droid.destroy
  Droid Load (0.7ms)  SELECT `droids`.* FROM `droids` WHERE `droids`.`id` = 1 LIMIT 1
  TRANSACTION (0.4ms)  BEGIN
  Character Load (0.6ms)  SELECT `characters`.* FROM `characters` WHERE `characters`.`char_id` = 1 AND `characters`.`char_type` = 'Droid' LIMIT 1
  Character Destroy (0.7ms)  DELETE FROM `characters` WHERE `characters`.`id` = 2
  Droid Destroy (0.7ms)  DELETE FROM `droids` WHERE `droids`.`id` = 1
  TRANSACTION (2.2ms)  COMMIT
=> #<Droid id: 1, primary_function: "talk", created_at: "2020-12-15 05:29:45.744823000 +0000", updated_at: "2020-12-15 05:29:45.744823000 +0000">

GraphQL

ここからはGraphQLのsearchクエリーを実装していきます。

まずは各モデルのタイプを作ります。

app/graphql/types/human_type.rb
module Types
  class HumanType < BaseObject
    field :id, ID, null: false
    field :name, String, null: false
    field :height, Float, null: false
  end
end
app/graphql/types/droid_type.rb
module Types
  class DroidType < BaseObject
    field :id, ID, null: false
    field :name, String, null: false
    field :primary_function, String, null: false
  end
end
app/graphql/types/starship_type.rb
module Types
  class StarshipType < BaseObject
    field :id, ID, null: false
    field :name, String, null: false
    field :length, Float, null: false
  end
end

上記をまとめたCharacterタイプを作ります。やっとUnionタイプの登場です。
possible_typesに登場するタイプを指定します。
self.resolve_typeでタイプの判定方法と対応するオブジェクトを返却します。

app/graphql/types/character_type.rb
module Types
  class CharacterType < Types::BaseUnion
    possible_types Types::HumanType, Types::DroidType, Types::StarshipType

    def self.resolve_type(object, context)
      if object.human?
        [Types::HumanType, object.human]
      elsif object.droid?
        [Types::DroidType, object.droid]
      elsif object.starship?
        [Types::StarshipType, object.starship]
      end
    end
  end
end

続いてクエリータイプです。
私はresolverをquery_type.rbと別クラスに定義する書き方をよくするので、今回も別クラスでSearchResolverを定義しています。
例のようにtextを指定するとnameで部分一致検索をするようにしました。

app/graphql/resolvers/search_resolver.rb
module Resolvers
  class SearchResolver < BaseResolver
    type Types::CharacterType.connection_type, null: false

    argument :text, String, required: false

    def resolve(text: nil)
      text.nil? ? Character.all : Character.where('name like ?', "%#{text}%")
    end
  end
end
app/graphql/types/query_type.rb
module Types
  class QueryType < Types::BaseObject
    field :search, resolver: Resolvers::SearchResolver
  end
end

実行

ここまでで実装完了です。
早速実行してみましょう。

まずは全件検索です。

{
  search {
    edges {
      node {
        ... on Human {
          name
          height
        }
        ... on Droid {
          name
          primaryFunction
        }
        ... on Starship {
          name
          length
        }
      }
    }
  }
}

下記のように各タイプが混在して返却されました。

{
  "data": {
    "search": {
      "edges": [
        {
          "node": {
            "name": "Han Solo",
            "height": 1.8
          }
        },
        {
          "node": {
            "name": "C-3PO",
            "primaryFunction": "talk"
          }
        },
        {
          "node": {
            "name": "TIE Advanced x1",
            "length": 9.2
          }
        },
        {
          "node": {
            "name": "Han Soloooo",
            "height": 1.8
          }
        }
      ]
    }
  }
}

続いてtextで絞り込んでみます。

{
  search(text: "an") {
    edges {
      node {
        ... on Human {
          name
          height
        }
        ... on Droid {
          name
          primaryFunction
        }
        ... on Starship {
          name
          length
        }
      }
    }
  }
}

きちんと"an"が含まれるデータのみ返却されるようになりました。

{
  "data": {
    "search": {
      "edges": [
        {
          "node": {
            "name": "Han Solo",
            "height": 1.8
          }
        },
        {
          "node": {
            "name": "TIE Advanced x1",
            "length": 9.2
          }
        }
        {
          "node": {
            "name": "Han Soloooo",
            "height": 1.8
          }
        }
      ]
    }
  }
}

最後にクエリーを確認してみます。
対応するテーブル(humans, droids, starships)へのselectが1件ごとに実行されています。
また共通項目nameの取得時にcharactersテーブルへのselectも動いているようです。
まさにN+1地獄ですね・・・
これだと実践での利用は厳しいので先読みできないか調査してみました。

  Character Load (0.8ms)  SELECT `characters`.* FROM `characters`
  ↳ app/controllers/graphql_controller.rb:13:in `execute'
  Human Load (0.7ms)  SELECT `humans`.* FROM `humans` WHERE `humans`.`id` = 1 LIMIT 1
  ↳ app/graphql/types/character_type.rb:7:in `resolve_type'
  Character Load (0.7ms)  SELECT `characters`.* FROM `characters` WHERE `characters`.`char_id` = 1 AND `characters`.`char_type` = 'Human' LIMIT 1
  ↳ app/models/concerns/char.rb:6:in `name'
  Droid Load (0.7ms)  SELECT `droids`.* FROM `droids` WHERE `droids`.`id` = 1 LIMIT 1
  ↳ app/graphql/types/character_type.rb:9:in `resolve_type'
  Character Load (0.6ms)  SELECT `characters`.* FROM `characters` WHERE `characters`.`char_id` = 1 AND `characters`.`char_type` = 'Droid' LIMIT 1
  ↳ app/models/concerns/char.rb:6:in `name'
  Starship Load (0.5ms)  SELECT `starships`.* FROM `starships` WHERE `starships`.`id` = 1 LIMIT 1
  ↳ app/graphql/types/character_type.rb:11:in `resolve_type'
  Character Load (0.4ms)  SELECT `characters`.* FROM `characters` WHERE `characters`.`char_id` = 1 AND `characters`.`char_type` = 'Starship' LIMIT 1
  ↳ app/models/concerns/char.rb:6:in `name'
  Human Load (0.5ms)  SELECT `humans`.* FROM `humans` WHERE `humans`.`id` = 2 LIMIT 1
  ↳ app/graphql/types/character_type.rb:7:in `resolve_type'
  Character Load (0.7ms)  SELECT `characters`.* FROM `characters` WHERE `characters`.`char_id` = 2 AND `characters`.`char_type` = 'Human' LIMIT 1
  ↳ app/models/concerns/char.rb:6:in `name'

SearchResolverで取得するときにpreloadするようにリファクタリングしてみました。
最初はpreload(:char)だけでいけるかなと思ったのですが、各モデルからCharacterモデルを参照するname取得処理でselectが発行されてしまったので、:characterも追記しました。

app/graphql/resolvers/search_resolver.rb
    def resolve(text: nil)
-     text.nil? ? Character.all : Character.where('name like ?', "%#{text}%")
+     chars = Character.all
+     chars = chars.where('name like ?', "%#{text}%") if text
+     chars.preload(char: :character)
    end

下記のようにまとめて取得されるようになりました。(クエリーが多いので見ずらいですが、データが2件存在するhumansがまとめて取得されるようになりました)

  Character Load (0.5ms)  SELECT `characters`.* FROM `characters`
  ↳ app/controllers/graphql_controller.rb:13:in `execute'
  Human Load (0.7ms)  SELECT `humans`.* FROM `humans` WHERE `humans`.`id` IN (1, 2)
  ↳ app/controllers/graphql_controller.rb:13:in `execute'
  Droid Load (0.3ms)  SELECT `droids`.* FROM `droids` WHERE `droids`.`id` = 1
  ↳ app/controllers/graphql_controller.rb:13:in `execute'
  Starship Load (0.4ms)  SELECT `starships`.* FROM `starships` WHERE `starships`.`id` = 1
  ↳ app/controllers/graphql_controller.rb:13:in `execute'
  Character Load (0.4ms)  SELECT `characters`.* FROM `characters` WHERE `characters`.`char_type` = 'Human' AND `characters`.`char_id` IN (1, 2)
  ↳ app/controllers/graphql_controller.rb:13:in `execute'
  Character Load (0.5ms)  SELECT `characters`.* FROM `characters` WHERE `characters`.`char_type` = 'Droid' AND `characters`.`char_id` = 1
  ↳ app/controllers/graphql_controller.rb:13:in `execute'
  Character Load (0.9ms)  SELECT `characters`.* FROM `characters` WHERE `characters`.`char_type` = 'Starship' AND `characters`.`char_id` = 1
  ↳ app/controllers/graphql_controller.rb:13:in `execute'

最後に

最後まで読んでいただきありがとうございます :bow:
明日はVISITSのエンジニアリングマネージャー @kotala_b の記事です。お楽しみに!!

27
8
7

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
27
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?