本記事の趣旨
Contentfulについて別記事に書きましたが、
なかなか使用するにはハードルを感じたので、私が作成した共通処理を一例として公開したいと思います。
まだまだ荒いところもあるかと思うので、あくまで参考まで。
前の記事
https://qiita.com/polar_bear_tech/items/fac1fbc345662c396e5b
Contentful Gem(読み込み用)の難しいところ
以下のポイントを解決するようモデルと共通処理を作っていきます。
- Contentful::Entryモデルを継承してinitializeでフィールドを設定する必要がある
- エントリー取得時のクエリが長く、感覚的に使いにくい
- モデル間のリレーションがあると深いJSONになり扱いにくい
基本的な実装
Contentfulで作成したモデルには"contentful_readable.rb"をincludeすることにしています。
ここにclientを実装する時、entry_mappingにContentfulGemで扱うモデルをマッピングします。
require 'contentful'
module ContentfulReadable
extend ActiveSupport::Concern
included do
class << self
def cda_client
@cda_client ||= Contentful::Client.new(
space: ENV['CONTENTFUL_SPACE_ID'],
access_token: ENV['CONTENTFUL_DELIVERY_ACCESS_TOKEN'],
environment: ENV['CONTENTFUL_ENVIRONMENT'] || 'master',
# ↓ここの数値が多いとリレーション項目を持ってきてしまってクエリが重くなる
max_include_resolution_depth: 1,
timeout_read: 30,
timeout_write: 30,
timeout_connect: 10,
# Contentfulのモデルをマッピングする。左辺がContentModelのID、右辺がモデルクラス
entry_mapping: {
'user' => User
}
)
end
end
end
end
ContentfulGemで扱われるモデルには、Contentful::Entryを継承させます。
忘れず先程の共通処理をincludeさせます。
id,first_name,usersとタイムスタンプのフィールドを持つモデルとして、アクセサを定義した時、
initializeではそれぞれに初期化時に値を入れるよう処理を記載する必要があります。
class User < Contentful::Entry
include ContentfulReadable
attr_accessor :id,
:first_name,
:user_ids, # ユーザー同士の多対多リレーション項目usersのIDを配列で持つ
:created_at,
:updated_at
def initialize(
item = {},
_configuration = {},
localized = false,
includes = [],
entries = {},
depth = 0,
errors = []
)
# Contentfulがデフォルトで持っている項目はsysから
self.id = item.dig('sys', 'id')
self.created_at = item.dig('sys', 'createdAt')
self.updated_at = item.dig('sys', 'updatedAt')
# Content Model定義時に設定したフィールドはfieldsから
self.first_name = item.dig('fields', 'firstName')
# リレーション項目はさらにネストされたsysから
self.user_ids = item.dig('fields', 'users').map { |hash| dig('sys', 'id') }
# 注) 上記は必ず値が入っている場合の例
# Contentful::Entryのinitializeも動くようにsuperを実行
super
end
end
このモデルのクエリを発行するとき、以下のようになります。
User.cda_client.entries(
content_type: 'user',
'fields.firstName[eq]' => 'Tarou',
'fields.users.sys.contentType.sys.id' => 'user',
'fields.users.fields.firstName[eq]' => 'Jun'
)
[eq]というのは一致条件で、他にも[ne][in][lte][gte]などがあります。(詳しくはCDAのリファレンスを参照)
条件が多かったり、リレーション項目で条件をつける場合、例のように長くなります。
共通処理を充実させて簡単にしてみる
結論から、共通処理の最終形です。
# frozen_string_literal: true
require 'contentful'
module ContentfulReadable
extend ActiveSupport::Concern
included do
class << self
def relation_field(field)
@relations = [] if @relations.nil?
@relations.push(field)
end
attr_reader :relations
# すべてのユーザーを取得する
def all
cnt_array = cda_client.entries(content_type: name.camelize(:lower))
items = cnt_array.items.dup
Enumerator::Lazy.new(0...cnt_array.total) do |yielder, _index|
yielder << items.shift
if items.empty?
cnt_array = cnt_array.next_page(cda_client)
items = cnt_array.items.dup
end
end
end
def total
cda_client.entries(content_type: name.camelize(:lower), limit: 0).total
end
alias_method :count, :total
# Hash型式で条件を渡すと一致するエントリを配列で返す
# conditions<Hash>:
# keyに検索対象のフィールド名と演算子名をsnakeケースで設定し、検索値をvalueに設定する。
# 演算子名はcontentfulのAPIドキュメントに準拠する
# https://www.contentful.com/developers/docs/references/content-delivery-api/#/reference/search-parameters
# 例: { field_name_eq: value, field_name_gte: value }
# order<Hash>:
# keyに順序の指定項目を、valueにasc/descを指定する。
# 複数条件を指定した場合、先の要素が優先される。(例の場合はupdated_atの降順の後にidで昇順となる)
# 例: { updated_at: "desc", id: "asc" }
# include_level<Integer>: 複数のエントリーを取得する場合に、関係を持つエントリーを併せて取得する
# limit<Integer>: 取得エントリー数の上限(default: 100)
# skip<Integer>: クエリ総数がlimitを超える場合に、スキップするページ数を指定する
def where(conditions, order: { updated_at: 'desc', id: 'asc' }, include_level: 0, limit: 100, skip: 0)
query_params = {
content_type: name.camelize(:lower),
include: include_level,
order: order_format(order),
limit: limit,
skip: skip
}
conditions_format(conditions).each { |k, v| query_params.store(k, v) }
cda_client.entries(query_params).to_a
end
def find_by(conditions, include_level: 0, order: { updated_at: 'desc', id: 'asc' })
query_params = {
content_type: name.camelize(:lower),
include: include_level,
order: order_format(order),
limit: 1
}
conditions_format(conditions).each { |k, v| query_params.store(k, v) }
cda_client.entries(query_params).first
end
# ID指定で1件取得して返す
def find(id)
cda_client.entry(id)
end
def cda_client
@cda_client ||= Contentful::Client.new(
space: ENV['CONTENTFUL_SPACE_ID'],
access_token: ENV['CONTENTFUL_DELIVERY_ACCESS_TOKEN'],
environment: ENV['CONTENTFUL_ENVIRONMENT'] || 'master',
max_include_resolution_depth: 1,
timeout_read: 30,
timeout_write: 30,
timeout_connect: 10,
entry_mapping: {
'user' => User
}
)
end
private
# 順序指定条件のHashをContebntfulに渡せる形式に変換する
# in: { updated_at: desc, id: asc }
# out: "-sys.updatedAt,sys.id"
def order_format(order_hash)
formatted_orders = []
order_hash.each do |field, order|
camelized_field = field.to_s.camelize(:lower)
prefix = %w[id createdAt updatedAt].include?(camelized_field) ? 'sys' : 'fields'
order_symbol = order.casecmp('desc').zero? ? '-' : ''
formatted_orders.push("#{order_symbol}#{prefix}.#{camelized_field}")
end
formatted_orders.join(',')
end
# 検索条件のHashをContebntfulに渡せる形式に変換する
def conditions_format(conditions)
formatted_conditions = {}
conditions.each do |field_cnd, value|
if value.is_a?(Hash)
conditions_format(value).each do |link_condition, link_value|
camelized_field = field_cnd.to_s.camelize(:lower)
formatted_conditions.store("fields.#{camelized_field}.sys.contentType.sys.id", camelized_field)
formatted_conditions.store("fields.#{camelized_field}.#{link_condition}", link_value)
end
elsif field_cnd.to_s == 'query'
formatted_conditions.store(:query, value)
else
field_cnd_array = field_cnd.to_s.split('_')
cnd = field_cnd_array.pop
cnd = cnd == 'eq' ? '' : "[#{cnd}]"
if cnd == '' && value.nil?
cnd = '[exists]'
value = false
end
field = field_cnd_array.join('_')
camelized_field = field.to_s.camelize(:lower)
prefix = %w[id createdAt updatedAt].include?(camelized_field) ? 'sys' : 'fields'
formatted_conditions.store("#{prefix}.#{camelized_field}#{cnd}", value)
end
end
formatted_conditions
end
end
end
private
def initialize_field_set(item)
return if item.blank?
self.id = item.dig('sys', 'id')
self.created_at = item.dig('sys', 'createdAt')
self.updated_at = item.dig('sys', 'updatedAt')
item['fields'].each do |key, value|
if self.class.relations.present? && self.class.relations.include?(key.underscore)
case value
when Hash
send("#{key.underscore}_id=", value.dig('sys', 'id')) if self.class.method_defined?("#{key.underscore}_id=")
when Array
if self.class.method_defined?("#{key.underscore.singularize}_ids=")
send("#{key.underscore.singularize}_ids=", value.map { |hash| hash.dig('sys', 'id') })
end
end
elsif self.class.method_defined?("#{key.underscore}=")
send("#{key.underscore}=", value)
end
end
end
end
併せてモデルの最終形。
class User < Contentful::Entry
include ContentfulReadable
attr_accessor :id,
:first_name,
:user_ids, # ユーザー同士の多対多リレーション項目usersのIDを配列で持つ
:created_at,
:updated_at
relation_field 'users'
def initialize(
item = {},
_configuration = {},
localized = false,
includes = [],
entries = {},
depth = 0,
errors = []
)
# 共通処理
initialize_field_set(item)
# Contentful::Entryのinitializeも動くようにsuperを実行
super
end
# usersでリレーションを持つ、Userモデルを取得する
def friends
return [] if user_ids.blank?
@users ||= User.where(
{ id_in: user_ids },
order: { updated_at: 'desc'}
)
end
end
上記までの最終形では、
Contentfulのフィールド名とRails上のフィールド名に一定のルールを設け、
それに則った状態で使用できるようにしています。たとえば下記のような感じ。
項目 | Contentful | Rails |
---|---|---|
非リレーション項目 | firstName | first_name |
複数リレーション項目 | friendUsers | friend_user_ids |
単数リレーション項目 | friendUser | friend_user_id |
軽くそれぞれのポイントを触れていきます。
リレーション項目の表現
共通処理から抜粋。
ここでリレーション項目をモデルに定義できるようにしています。
定義されたリレーション項目はinitialize、クエリの共通処理で使用しています。
def relation_field(field)
@relations = [] if @relations.nil?
@relations.push(field)
end
attr_reader :relations
initializeの簡略化
共通処理から抜粋。
コメントの通り、命名ルールに則って初期値を入れていきます。
def initialize_field_set(item)
return if item.blank?
# Contentfulのデフォルト項目はそのまま入れる
self.id = item.dig('sys', 'id')
self.created_at = item.dig('sys', 'createdAt')
self.updated_at = item.dig('sys', 'updatedAt')
# ContentModelに定義した項目は・・・
item['fields'].each do |key, value|
# リレーション項目なら
if self.class.relations.present? && self.class.relations.include?(key.underscore)
case value
# 単数リレーションなら
when Hash
# IDを所定の名前の項目に入れる
send("#{key.underscore}_id=", value.dig('sys', 'id')) if self.class.method_defined?("#{key.underscore}_id=")
# 複数リレーションなら
when Array
if self.class.method_defined?("#{key.underscore.singularize}_ids=")
# IDの配列を所定の名前の項目に入れる
send("#{key.underscore.singularize}_ids=", value.map { |hash| hash.dig('sys', 'id') })
end
end
# 非リレーション項目なら
elsif self.class.method_defined?("#{key.underscore}=")
# 値を所定の名前の項目に入れる
send("#{key.underscore}=", value)
end
end
end
クエリの簡略化
共通処理から抜粋。
コメント記載のルールで条件を指定して、クエリを実行します。
# Hash型式で条件を渡すと一致するエントリを配列で返す
# conditions<Hash>:
# keyに検索対象のフィールド名と演算子名をsnakeケースで設定し、検索値をvalueに設定する。
# 演算子名はcontentfulのAPIドキュメントに準拠する
# https://www.contentful.com/developers/docs/references/content-delivery-api/#/reference/search-parameters
# 例: { field_name_eq: value, field_name_gte: value }
# order<Hash>:
# keyに順序の指定項目を、valueにasc/descを指定する。
# 複数条件を指定した場合、先の要素が優先される。(例の場合はupdated_atの降順の後にidで昇順となる)
# 例: { updated_at: "desc", id: "asc" }
# include_level<Integer>: 複数のエントリーを取得する場合に、関係を持つエントリーを併せて取得する
# limit<Integer>: 取得エントリー数の上限(default: 100)
# skip<Integer>: クエリ総数がlimitを超える場合に、スキップするページ数を指定する
def where(conditions, order: { updated_at: 'desc', id: 'asc' }, include_level: 0, limit: 100, skip: 0)
query_params = {
content_type: name.camelize(:lower),
include: include_level,
order: order_format(order),
limit: limit,
skip: skip
}
# 条件指定をContentful形式に整えてパラメータにマージする
conditions_format(conditions).each { |k, v| query_params.store(k, v) }
# エントリーを取得
cda_client.entries(query_params).to_a
end
# 順序指定条件のHashをContebntfulに渡せる形式に変換する
# in: { updated_at: desc, id: asc }
# out: "-sys.updatedAt,sys.id"
def order_format(order_hash)
formatted_orders = []
order_hash.each do |field, order|
# キャメルケースに変換(created_at => createdAt)
camelized_field = field.to_s.camelize(:lower)
# sysかfieldsを判断
prefix = %w[id createdAt updatedAt].include?(camelized_field) ? 'sys' : 'fields'
# desc指定の場合は-をつける
order_symbol = order.casecmp('desc').zero? ? '-' : ''
formatted_orders.push("#{order_symbol}#{prefix}.#{camelized_field}")
end
# 条件をカンマ区切り文字列にして返す
formatted_orders.join(',')
end
# 検索条件のHashをContebntfulに渡せる形式に変換する
def conditions_format(conditions)
formatted_conditions = {}
# 下記のクエリを発行した場合(ユーザー名Taroで、フレンドにJunがいるユーザー)
# User.where({
# first_name_eq: 'Taro',
# users: { first_name_eq: 'Jun' }
# })
conditions.each do |field_cnd, value|
# リレーション項目の条件指定の場合
if value.is_a?(Hash)
# 指定された{ first_name_eq: 'Jun' }を再起的に取得して
# { 'fields.users.firstName[eq]' => 'Jun' }をEachで回す
conditions_format(value).each do |link_condition, link_value|
camelized_field = field_cnd.to_s.camelize(:lower)
# リレーション項目のID指定条件を追加 { "fields.users.sys.contentType.sys.id" => 'user' }
formatted_conditions.store("fields.#{camelized_field}.sys.contentType.sys.id", camelized_field)
# リレーション項目のfirstName条件を追加 { "fields.users.fields.firstName[eq]" => 'Jun' }
formatted_conditions.store("fields.#{camelized_field}.#{link_condition}", link_value)
end
# 'query'が指定されていた場合、条件をそのまま入れる
elsif field_cnd.to_s == 'query'
formatted_conditions.store(:query, value)
# 非リレーション項目の条件指定の場合
else
# first_name_eq => firstName[eq]にする
field_cnd_array = field_cnd.to_s.split('_')
cnd = field_cnd_array.pop
cnd = cnd == 'eq' ? '' : "[#{cnd}]"
if cnd == '' && value.nil?
cnd = '[exists]'
value = false
end
field = field_cnd_array.join('_')
camelized_field = field.to_s.camelize(:lower)
# Contentfulのデフォルト項目はsys,それ以外はfieldsをつける
prefix = %w[id createdAt updatedAt].include?(camelized_field) ? 'sys' : 'fields'
# 条件を追加 { 'fields.firstName[eq]' => 'Taro' }
formatted_conditions.store("#{prefix}.#{camelized_field}#{cnd}", value)
end
end
# 条件のHashを返す
# {
# 'fields.firstName[eq]' => 'Taro',
# 'fields.users.sys.contentType.sys.id' => 'user',
# 'fields.users.fields.firstName[eq]' => 'Jun'
# }
formatted_conditions
end
例えば、ユーザーモデルから抜粋して、こんなかんじに。
CDAのクエリ用にeq,ne,gthとかの条件指定文字列の名残はありますが、
冗長だったfields.sys...うんぬんはリレーションを見て勝手に補完するようにしてすっきりしました。
ActiveRecordちっくに扱えるのでマシになったかと個人的に思っています。
# usersのUserモデルを取得する
def friends
return [] if user_ids.blank?
@users ||= User.where(
{ id_in: user_ids },
order: { updated_at: 'desc'}
)
end
あとがき
サンプル実装がコピーしてそのまま動かなかったらすみません、元実装を編集したので・・・
大丈夫だと思いますが、あくまで参考にしてください。
あまり私が実装していた時点では参考記事が少なかったので、一助になればと思います。
共通処理をここまで充実させれば、割と不自由なくContentfulが扱えますが、
「OR条件検索ができない」とかそういう限界は乗り越えられないのでご注意くださいね。
書き込み用Gem ContentfulManagementについては、いずれ・・・いずれ書くかもしれません。
需要があればその旨コメントいただけるとモチベーションになります。