GraphQL は REST などと同じく、DB のテーブルをそのまま露出するとは限らない

この記事は GraphQL Advent Calendar 2020 の 1 日目の記事です。
https://qiita.com/advent-calendar/2020/graphql

---

REST API などと同じようにあくまで設計次第という話です。

例えば、Organization と User があるとします。
Organization と User は N:N の関係にあるとします。

このとき、Organization と User の関連を示すのに、多くの場合中間テーブルを設けるでしょう。
これを Membership とします。

Organization 1-* Membership *-1 User

このとき、Membership はあくまで内部実装として API 上は隠蔽することも、露出することもできます。

以下のコードたちは保存して `$ ruby foo.rb` していただければそのまま実行可能です。

## Membership を見せない場合

```ruby
require 'bundler/inline'

gemfile do
  source 'https://rubygems.org'

  gem 'graphql'
  gem 'activerecord', require: 'active_record'
  gem 'sqlite3'
end

require 'logger'

ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
ActiveRecord::Base.logger = Logger.new(STDOUT)

ActiveRecord::Schema.define do
  create_table :organizations, force: true do |t|
  end

  create_table :memberships, force: true do |t|
    t.references :organization
    t.references :user
  end

  create_table :users, force: true do |t|
  end
end

class Organization < ActiveRecord::Base
  has_many :memberships
  has_many :users, through: :memberships
end

class Membership < ActiveRecord::Base
  belongs_to :organization
  belongs_to :user
end

class User < ActiveRecord::Base
  has_many :memberships
  has_many :organizations, through: :memberships
end

org1 = Organization.create!
org2 = Organization.create!

user1 = User.create!
user2 = User.create!
user3 = User.create!

Membership.create!(organization: org1, user: user1)
Membership.create!(organization: org1, user: user2)
Membership.create!(organization: org1, user: user3)
Membership.create!(organization: org2, user: user1)

class UserType < GraphQL::Schema::Object
  field :id, ID, null: false
end

class OrganizationType < GraphQL::Schema::Object
  field :id, ID, null: false
  field :users, ['UserType'], null: false
end

class QueryType < GraphQL::Schema::Object
  field :organization, 'OrganizationType', null: true do
    argument :id, ID, required: true
  end

  def organization(id:)
    Organization.find(id)
  end
end

class Schema < GraphQL::Schema
  query QueryType
end

result = Schema.execute(<<~GQL, variables: {id: org1.id})
  query q($id: ID!) {
    organization(id: $id) {
      id
      users {
        id
      }
    }
  }
GQL

pp result.as_json
# {"data"=>
#   {"organization"=>
#     {"id"=>"1", "users"=>[{"id"=>"1"}, {"id"=>"2"}, {"id"=>"3"}]}}}

result = Schema.execute(<<~GQL, variables: {id: org2.id})
  query q($id: ID!) {
    organization(id: $id) {
      id
      users {
        id
      }
    }
  }
GQL

pp result.as_json
# {"data"=>{"organization"=>{"id"=>"2", "users"=>[{"id"=>"1"}]}}}
```

## Membership を見せる場合

```ruby
require 'bundler/inline'

gemfile do
  source 'https://rubygems.org'

  gem 'graphql'
  gem 'activerecord', require: 'active_record'
  gem 'sqlite3'
end

require 'logger'

ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
ActiveRecord::Base.logger = Logger.new(STDOUT)

ActiveRecord::Schema.define do
  create_table :organizations, force: true do |t|
  end

  create_table :memberships, force: true do |t|
    t.references :organization
    t.references :user
  end

  create_table :users, force: true do |t|
  end
end

class Organization < ActiveRecord::Base
  has_many :memberships
  has_many :users, through: :memberships
end

class Membership < ActiveRecord::Base
  belongs_to :organization
  belongs_to :user
end

class User < ActiveRecord::Base
  has_many :memberships
  has_many :organizations, through: :memberships
end

org1 = Organization.create!
org2 = Organization.create!

user1 = User.create!
user2 = User.create!
user3 = User.create!

Membership.create!(organization: org1, user: user1)
Membership.create!(organization: org1, user: user2)
Membership.create!(organization: org1, user: user3)
Membership.create!(organization: org2, user: user1)

class UserType < GraphQL::Schema::Object
  field :id, ID, null: false
  field :memberships, ['MembershipType'], null: false
end

class MembershipType < GraphQL::Schema::Object
  field :id, ID, null: false
  field :user, UserType, null: false
  field :organization, 'OrganizationType', null: false
end

class OrganizationType < GraphQL::Schema::Object
  field :id, ID, null: false
  field :memberships, ['MembershipType'], null: false
end

class QueryType < GraphQL::Schema::Object
  field :organization, 'OrganizationType', null: true do
    argument :id, ID, required: true
  end

  def organization(id:)
    Organization.find(id)
  end
end

class Schema < GraphQL::Schema
  query QueryType
end

result = Schema.execute(<<~GQL, variables: {id: org1.id})
  query q($id: ID!) {
    organization(id: $id) {
      id
      memberships {
        user {
          id
        }
      }
    }
  }
GQL

pp result.as_json
# {"data"=>
#   {"organization"=>
#     {"id"=>"1",
#      "memberships"=>
#       [{"user"=>{"id"=>"1"}}, {"user"=>{"id"=>"2"}}, {"user"=>{"id"=>"3"}}]}}}

result = Schema.execute(<<~GQL, variables: {id: org2.id})
  query q($id: ID!) {
    organization(id: $id) {
      id
      memberships {
        user {
          id
        }
      }
    }
  }
GQL

pp result.as_json
# {"data"=>{"organization"=>{"id"=>"2", "memberships"=>[{"user"=>{"id"=>"1"}}]}}}
```

## まとめ

Graph "QL" とついているからか、SQL を投げるかのように DB をそのまま露出させると勘違いしている方がいるのではないかという懸念があり書きました。

Membership を見せるか見せないかと同じように、id や created_at など、個別のカラムについても返すべきか制御可能です。
また、隠すのとは逆に DB 上に実際は無いものを見せることも可能です。(例: DB 上はない full_name として first_name + last_name を見せる)

GraphQL API にしろ REST API にしろ、クライアントから見て使いやすい API はどういうものか、扱いたいリソースが何かを考えて設計することは変わりません。
特に API クライアントとサーバで実装者 (チーム) が完全に分かれている場合には、どういった形のほうがお互いに実装しやすいか、設計として自然かを、お互いの視点から話し合うことが重要でしょう。