seedは初期データやマスタデータを投入する仕組みですが、自分で実装する必要があり、データが増える前にyamlを読むように実装ているケースが多いのではないでしょうか。
対象テーブル追加毎にdb/seeds.rbに手を入れるのも手間ですし、データが増えたら高速化も必要になり毎回同じような対応をするのも手間なので、汎用的に使えるように実装してみました。
工夫した所や手間取った所にコメント入れておきますので、参考または使って頂けると嬉しいです。
デフォルトのseeds.rb
db/seeds.rb
# This file should contain all the record creation needed to seed the database with its default values.
# The data can then be loaded with the rails db:seed command (or created alongside the database with db:setup).
#
# Examples:
#
# movies = Movie.create([{ name: 'Star Wars' }, { name: 'Lord of the Rings' }])
# Character.create(name: 'Luke', movie: movies.first)
データが少なければサンプルのようにやっても良いですが、増えるとしんどいですね。
要件
・初期データ: 画面や処理で更新されるもの → INSERTのみ、UPDATE・DELETEしない
・テストデータ:development向け → INSERT/UPDATEのみ、DELETEしない
・マスタデータ: 画面や処理で更新されないもの → INSERT/UPDATE/DELETEする
・環境により使用有無を切り替えられる
・BULKで高速化 → insert_all!/upsert_all/delete_all
・更新対象外カラムを指定できる → 例:password存在しない(encrypted_passwordが存在)
seeds.ymlで対象を定義
最新のコードこちら → https://dev.azure.com/nightonly/rails-app-origin/_git/rails-app-origin?path=/db/seeds.yml&version=GBdevelop
db/seeds.yml
- file: 'seed/admin_users.yml'
model: 'AdminUser'
insert: true
env:
production: true
development: true
- file: 'seed/users_development.yml'
model: 'User'
insert: true
update: true
option:
bulk_update: true
exclude_update_column:
- 'password'
env:
development: true
- file: 'seed/infomations_development.yml'
model: 'Infomation'
insert: true
update: true
delete: true
option:
bulk_insert: true
bulk_update: true
destroy: true
env:
development: true
各yamlファイルはこちら → https://dev.azure.com/nightonly/rails-app-origin/_git/rails-app-origin?path=/db/seed&version=GBdevelop
seeds.rbを汎用的に実装
最新のコードこちら → https://dev.azure.com/nightonly/rails-app-origin/_git/rails-app-origin?path=/db/seeds.rb&version=GBdevelop
db/seeds.rb
# This file should contain all the record creation needed to seed the database with its default values.
# The data can then be loaded with the rails db:seed command (or created alongside the database with db:setup).
BULK_MAX_COUNT = 1000
# シーケンス更新 # Tips: id指定でinsert_allした場合、シーケンスが更新されない為(PostgreSQL)
def update_sequence
return if @model.connection_config[:adapter] != 'postgresql'
@model.connection.execute(
"SELECT setval(pg_get_serial_sequence('#{@model.table_name}', 'id'), (SELECT MAX(id) FROM #{@model.table_name}))"
)
end
PostgreSQLの場合、シーケンスが更新されないので、画面等で登録する時に存在するIDが発番されエラーになってしまうで、明示的にシーケンスを更新するようにしました。
# 登録処理
def insert_contents(bulk_insert)
count = 0
insert_datas = []
datas = @model.where(id: @ids)
data = @model.new
now = Time.current
(@ids - datas.ids).each do |id|
content = @contents[id]
unless bulk_insert
@model.create!(content)
count += 1
next
end
Deviseのテーブル(users等)のencrypted_passwordには、secret_key(通常、環境毎に異なる)でhash化された値が入るので、hash値をyamlで定義するのは手間が大きい。
カラムとしては存在しないpasswordに生パスワードを入れて、モデルで登録や更新するとencrypted_passwordにセットして保存してくれるので、BULKではない通常のINSERTも使えるようにしました。
insert_datas.push(data.attributes.merge(content).merge(created_at: now, updated_at: now))
yamlで定義しているカラムに不足がある場合、下記エラーが発生します。
> ArgumentError: All objects being inserted must have the same keys
(挿入されるすべてのオブジェクトには同じキーが必要です)
レコード追加時に漏れがないように気にしたり、追加カラムがあると全レコードに追加が必要になるので手間が大きい。newして、yamlの内容とcreated_at/updated_atをmergeする事で、カラム(キー)不足がなくなるようにしました。
next if insert_datas.count < BULK_MAX_COUNT
@model.insert_all!(insert_datas)
count += insert_datas.count
insert_datas = []
end
if insert_datas.present?
@model.insert_all!(insert_datas)
count += insert_datas.count
end
件数が多いとSQLが長くなり、DBの制限を超えたり、不安定になるので、BULKを指定件数で分割できるようにしました。
update_sequence if count.positive?
count
end
# 変更チェック
def data_changed?(content, data)
content.each { |key, value| return true if data[key] != value }
false
end
# 更新処理
def update_contents(bulk_update, exclude_update_column)
count = 0
update_datas = []
datas = @model.where(id: @ids)
INSERTしたデータも対象になりますが、あえて外さず(INSERTしたIDを除外すればできますが)、INSERT時に意図せず変更されてしまったデータをUPDATEするようにしました。
今回対象となったのは下記。メール未確認(期限切れ)のユーザー
db/seed/users_development.yml
confirmation_token: 'token000000000000014' # Tips: INSERT時に値が変わる -> UPDATEで担保 confirmation_sent_at: '2000-01-01 12:34:56+0900' # Tips: INSERT時に現在日時になる -> 〃
now = Time.current
datas.find_each do |data|
content = @contents[data.id]
next if content.blank?
exclude_update_column.each { |key| content.delete(key) } if exclude_update_column.present?
BULKで指定できない存在しないカラム(password等)を更新対象から外せるようにしました。
unless bulk_update
data.assign_attributes(content)
data.save!(validate: false)
count += 1 if data.updated_at > now
next
end
登録処理と同様に、通常のUPDATEも使えるようにしました。
存在しないカラム(password等)を更新したい場合は、こちらを使う事になります。
今回は、Deviseのメール送信処理が走るので、passwordを対象外にして、BULKを使うように設定しています。
ちなみに、更新されたかどうかはupdated_atで判断しています。
next unless data_changed?(content, data)
saveは更新がなければ、UPDATE分は発行されないので、updated_atも更新されないのですが、BULKでは自前で判断しなければならないので、変更がなければスキップするようにしました。
update_datas.push(data.attributes.merge(content).merge(updated_at: now))
insert_allと同様にカラムに不足がある場合、ArgumentErrorが発生するのと、未指定のカラムのデータがなくなってしまったり、not nullでエラーになったりするので、mergeで対応しています。
next if update_datas.count < BULK_MAX_COUNT
@model.upsert_all(update_datas)
count += update_datas.count
update_datas = []
end
if update_datas.present?
@model.upsert_all(update_datas)
count += update_datas.count
end
insert_all!と同様に、BULKを指定件数で分割できるようにしました。
count
end
# 削除処理
def delete_contents(destroy)
datas = @model.where.not(id: @ids)
count = datas.count
if destroy
datas.destroy_all
else
datas.delete_all
end
リレーション先も削除できるようにdestroy_allにも対応しました。
モデルで「dependent: :destroy」が設定されている場合。
count
end
total_insert_count = 0
total_update_count = 0
total_delete_count = 0
File.open("#{Rails.root}/db/seeds.yml") do |seed_body|
YAML.safe_load(seed_body).each do |seed|
if seed['env'][Rails.env] != true
p "== file: #{seed['file']} ... Skip"
next
end
環境により使用有無を切り替えられるようにしました。
p "== file: #{seed['file']}"
File.open("#{Rails.root}/db/#{seed['file']}") do |file_body|
@contents = YAML.safe_load(file_body).index_by { |content| content['id'] }
@ids = @contents.keys
@model = seed['model'].constantize
p "count: #{@contents.count}, model: #{@model}"
Rubyは値渡しになるので、大きなデータでメモリを消費しないようにインスタンス変数にしました。
option = seed['option'].present? ? seed['option'] : {}
insert_count = seed['insert'] == true ? insert_contents(option['bulk_insert'] == true) : nil
update_count = seed['update'] == true ? update_contents(option['bulk_update'] == true, option['exclude_update_column']) : nil
delete_count = seed['delete'] == true ? delete_contents(option['destroy'] == true) : nil
p "insert: #{insert_count.present? ? insert_count : '-'}, " \
"update: #{update_count.present? ? update_count : '-'}, " \
"delete: #{delete_count.present? ? delete_count : '-'}"
total_insert_count += insert_count if insert_count.present?
total_update_count += update_count if update_count.present?
total_delete_count += delete_count if delete_count.present?
end
end
end
p "Complete! ... Total insert: #{total_insert_count}, update: #{total_update_count}, delete: #{total_delete_count}"
標準出力に結果を出力して視覚的に確認できるようにしました。
ECRとかならCloudwatch Logsに残るので、後から確認する事もできますね。
実行結果
初回
% rails db:seed "== file: seed/admin_users.yml" "count: 1, model: AdminUser" "insert: 1, update: -, delete: -" "== file: seed/users_development.yml" "count: 13, model: User" "insert: 13, update: 1, delete: -" "== file: seed/infomations_development.yml" "count: 29, model: Infomation" "insert: 29, update: 0, delete: 0" "Complete! ... Total insert: 43, update: 1, delete: 0"
updateが走るのは、上記で記載した理由です。
2回目
% rails db:seed "== file: seed/admin_users.yml" "count: 1, model: AdminUser" "insert: 0, update: -, delete: -" "== file: seed/users_development.yml" "count: 13, model: User" "insert: 0, update: 0, delete: -" "== file: seed/infomations_development.yml" "count: 29, model: Infomation" "insert: 0, update: 0, delete: 0" "Complete! ... Total insert: 0, update: 0, delete: 0"
すべて0件なのでOK
