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