TL;DR
検索ロジック
module Types
class QueryType < Types::BaseObject
field :houses, [HouseType], null: true do
description "Find a houses ransack"
argument :q, BaseScalar, required: false # これが`any`タイプ
argument :page, Integer, required: false
argument :per, Integer, required: false
end
def houses(q: nil, page: nil, per: nil)
if q.present?
@q = House.joins(:city).ransack(q)
@q.sorts = 'id asc' if @q.sorts.blank? # デフォルト並び替えの追加
@q.result(distinct: true).page(page).per(per)
else
House.all
end
end
end
end
GraphQL
{
houses(q: {city_name_cont: "London", s: "name desc"}, page: 2, per: 4) {
name
}
}
結果
{
"data": {
"houses": [
{
"name": "Thomas"
},
{
"name": "Ruby"
},
{
"name": "Kelly"
},
{
"name": "Jeffrey"
}
]
}
}
解説
QueryType
まず、GraphQLはSchemaの定義が要求されているが、Ransackの述語
という概念と相性があまり良いとは言えません。
そこで出された案はanyタイプを利用することでした。GraphQLではBaseScalar
というタイプはany
タイプと近い使い方ができます。
下記のコードのように、q
というパラメータに任意のqueryを送信できます。
module Types
class QueryType < Types::BaseObject
field :houses, [HouseType], null: true do
argument :q, BaseScalar, required: false
end
def houses(q: nil, page: nil, per: nil)
end
end
end
{
houses(q: {city_name_cont: "London"}) {
name
}
}
Ransackの検索
Ransackについて詳しいの解説はこちらでは割愛いたします。こちらの記事を参考して、q
パラメータを組み立て、House.joins(:city).ransack(q)
のようにQueryを投げ込めばいいです。良いのも悪いのも、述語が間違っても、Ransackはエラーが発生しないことでした。
検索結果の並び替え
q: { s: 'id DESC' }
のように、q
パラメータの中に、s
フィールドを利用すれば、簡単に並び替えられます。また、q: { s: ['id DESC', 'name asc'] }
のように、配列を与えば、複数条件での並び替えも可能です。
デフォルトの並び替えについてですが、こちらの記事を参考して、以下のロジックを実装すれば簡単にできます。
module Types
class QueryType < Types::BaseObject
field :houses, [HouseType], null: true do
argument :q, BaseScalar, required: false
end
def houses(q: nil, page: nil, per: nil)
@q = House.joins(:city).ransack(q)
@q.sorts = 'id asc' if @q.sorts.blank? # デフォルト並び替えの追加
@q.result(distinct: true)
end
end
end
Kaminariでページネーション
普通、フォームで検索をする際、Kaminariはparams[:page]
を利用してページ切り替えを行います。ここでは似たような書き方で、page
Queryを定義します。また、フォームで検索をする場合、windowのサイズを固定するのが一般ですが、APIで検索する際、windowのサイズを可変にする方が使い勝手がいいので、per
Queryも追加します。
module Types
class QueryType < Types::BaseObject
field :houses, [HouseType], null: true do
argument :q, BaseScalar, required: false
argument :page, Integer, required: false
argument :per, Integer, required: false
end
def houses(q: nil, page: nil, per: nil)
@q = House.joins(:city).ransack(q)
@q.sorts = 'id asc' if @q.sorts.blank? # デフォルト並び替えの追加
@q.result(distinct: true).page(page).per(per)
end
end
end
おまけ
ユーザー認証機能
デフォルトの状態では、GraphQLは誰でも検索可能な状態で非常によくありません、JWTなどでログイン機能を追加することができます。
class GraphqlController < ApplicationController
skip_before_action :verify_authenticity_token # GraphQLはPOSTなので、ないと怒られる
before_action :authenticate_user!, unless: :from_graphiql?
# 省略...
private
# 開発環境でgraphiqlが開けなくなる恐れがあるので、パイパスを作る
def from_graphiql?
referrer = request.referer
if Rails.env.development? && referrer =~ /\/graphiql/
return true
end
end
end
また、execute
Actionにcontext
を設置すれば、query_type.rb
ではcurrent_user
などが使用可能になります。
class GraphqlController < ApplicationController
# 省略...
def execute
variables = ensure_hash(params[:variables])
query = params[:query]
operation_name = params[:operationName]
context = {
# Query context goes here, for example:
current_user: current_user,
}
result = CaesarisSchema.execute(query, variables: variables, context: context, operation_name: operation_name)
render json: result
rescue => e
raise e unless Rails.env.development?
handle_error_in_development e
end
# 省略...
end
camelCase
での検索
GraphQLの作法はcamelCase
ですが、RansackなどRailsのGemはsnake_case
であるため、houses(q: {city_name_cont: "London"})
のようなQueryは非常に気分が悪いです。
下記のメソッドを実装すれば、camelCase
での検索も可能になります。
module Types
class QueryType < Types::BaseObject
field :houses, [HouseType], null: true do
argument :q, BaseScalar, required: false
argument :page, Integer, required: false
argument :per, Integer, required: false
end
def houses(q: nil, page: nil, per: nil)
@q = House.joins(:city).ransack(form_ransack_params(q))
@q.sorts = 'id asc' if @q.sorts.blank? # デフォルト並び替えの追加
@q.result(distinct: true).page(page).per(per)
end
end
def form_ransack_params(params)
param_res = {}
params.each do |key, val|
key = key.to_s.underscore
if (key == 's' || key == 'sorts') && val.instance_of?(String)
param_res[key] = val.split(/\s+/).map(&:underscore).join(' ').strip
elsif (key == 's' || key == 'sorts') && val.instance_of?(Array)
param_res[key] = val.map{|v| v.split(/\s+/).map(&:underscore).join(' ').strip }
elsif (key == 'g' || key == 'groupings') && (val.instance_of?(Hash) || val.instance_of?(Array))
if val.instance_of?(Hash)
param_res[key] = val.values.map{|v| Util.form_ransack_params(v) }
else
param_res[key] = val.map{|v| Util.form_ransack_params(v) }
end
else
param_res[key] = val
end
end
param_res
end
end
{
houses(q: {cityNameCont: "London", s: "cityName DESC"}) {
name
}
}
参考
https://stackoverflow.com/questions/45598812/graphql-blackbox-any-type
https://qiita.com/jerrywdlee/items/a08589ea160c470e67f6
https://qiita.com/toshiotm@github/items/c7909b9bb97f845d831a
https://github.com/activerecord-hackery/ransack
https://github.com/rmosolgo/graphql-ruby/blob/master/guides/type_definitions/lists.md
https://github.com/rmosolgo/graphql-ruby/blob/master/guides/fields/arguments.md