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


今回のコミット内容
https://dev.azure.com/nightonly/rails-app-origin/_git/rails-app-origin/commit/228a5274fbb263047583fe9ff14946ada6db1396

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です