seed-fu (2.3.7)のコードリーディングをしました。
まず、SeedFu::Railtieでタスクを定義したりfixtureのパスを設定します。
module SeedFu
class Railtie < Rails::Railtie
rake_tasks do
load "tasks/seed_fu.rake"
end
initializer 'seed_fu.set_fixture_paths' do
SeedFu.fixture_paths = [
Rails.root.join('db/fixtures').to_s,
Rails.root.join('db/fixtures/' + Rails.env).to_s
]
end
end
end
rakeタスクはlib/tasks/seed_fu.rakeに定義されています。
require 'seed-fu'
namespace :db do
task :seed_fu => :environment do
if ENV["FILTER"]
filter = /#{ENV["FILTER"].gsub(/,/, "|")}/
end
if ENV["FIXTURE_PATH"]
fixture_paths = [ENV["FIXTURE_PATH"], ENV["FIXTURE_PATH"] + '/' + Rails.env]
end
SeedFu.seed(fixture_paths, filter)
end
end
SeedFu.seedはSeedRu::Runner#runを実行します
module SeedFu
# ...
@@fixture_paths = ['db/fixtures']
def self.seed(fixture_paths = SeedFu.fixture_paths, filter = nil)
Runner.new(fixture_paths, filter).run
end
end
# @public
class ActiveRecord::Base
extend SeedFu::ActiveRecordExtension
end
SeedFu::Runner#runはfixturesのパスに入っているファイル分だけ#run_fileを実行します
module SeedFu
class Runner
def run
puts "\n== Filtering seed files against regexp: #{@filter.inspect}" if @filter && !SeedFu.quiet
filenames.each do |filename|
run_file(filename)
end
end
run_fileはファイルの中身をevalします。(# BREAK EVAL
すると、そこでchunkをevalして再度chunkを作っていくみたいですね。大きいファイルを読み出す時の機能らしいです。初めて知った…)
def run_file(filename)
puts "\n== Seed from #{filename}" unless SeedFu.quiet
ActiveRecord::Base.transaction do
open(filename) do |file|
chunked_ruby = ''
file.each_line do |line|
if line == "# BREAK EVAL\n"
eval(chunked_ruby)
chunked_ruby = ''
else
chunked_ruby << line
end
end
eval(chunked_ruby) unless chunked_ruby == ''
end
end
end
fixtureに記述されるseedメソッドを見ていきます。SeedFu::Seeder#seedを呼び出します。
module SeedFu
module ActiveRecordExtension
def seed(*args, &block)
SeedFu::Seeder.new(self, *parse_seed_fu_args(args, block)).seed
end
# ...
private
def parse_seed_fu_args(args, block)
if block.nil?
if args.last.is_a?(Array)
# Last arg is an array of data, so assume the rest of the args are constraints
data = args.pop
[args, data]
else
# Partition the args, assuming the first hash is the start of the data
args.partition { |arg| !arg.is_a?(Hash) }
end
else
# We have a block, so assume the args are all constraints
[args, [SeedFu::BlockHash.new(block).to_hash]]
end
end
SeedFu::Seeder#seedは以下のようになっています。
module SeedFu
# ...
def seed
records = @model_class.transaction do
@data.map { |record_data| seed_record(record_data.symbolize_keys) }
end
update_id_sequence
records
end
@model_classはseedを呼び出したActiveRecord::BaseなのでActiveRecord::Base.transactionが呼ばれます。ハッシュの配列が@dataに入っており、それらの要素に対して#seed_recordが呼ばれます。
#seed_recordは#find_or_initialize_recordでレコードを取得、なければ新規にインスタンス生成を行い、ActiveRecord::Base#assign_attributesしてから#saveでvalidate無しで保存します。
def seed_record(data)
record = find_or_initialize_record(data)
return if @options[:insert_only] && !record.new_record?
puts " - #{@model_class} #{data.inspect}" unless @options[:quiet]
# Rails 3 or Rails 4 + rails/protected_attributes
if record.class.respond_to?(:protected_attributes) && record.class.respond_to?(:accessible_attributes)
record.assign_attributes(data, :without_protection => true)
# Rails 4 without rails/protected_attributes
else
record.assign_attributes(data)
end
record.save(:validate => false) || raise(ActiveRecord::RecordNotSaved, 'Record not saved!')
record
end
#find_or_initialize_recordは#constraint_conditionsでconstraintsとデータからwhere句のハッシュを生成します。constraintsが[:id]でdataが {id: 1, name: ‘hoge’}
の場合は{id: 1}
がwhere句になります。
def find_or_initialize_record(data)
@model_class.where(constraint_conditions(data)).take ||
@model_class.new
end
def constraint_conditions(data)
Hash[@constraints.map { |c| [c, data[c.to_sym]] }]
end
SeedFu::Writer
seed-fuにはfixtureファイルをジェネレートするSeedFu::Writerのクラスがあります。module SeedFu
class Writer
cattr_accessor :default_options
def self.write(io_or_filename, options = {}, &block)
new(options).write(io_or_filename, &block)
end
def write(io_or_filename, &block)
raise ArgumentError, "missing block" unless block_given?
if io_or_filename.respond_to?(:write)
write_to_io(io_or_filename, &block)
else
File.open(io_or_filename, 'w') do |file|
write_to_io(file, &block)
end
end
end
seed_headerで モデルクラス.seed(constraints
の部分を生成しつつ、block内のwriter.addでconstraints以降の引数を設定しています。writer.addの引数のハッシュは#inspectの文字列がそのまま書き出されます。
def <<(seed)
raise "You must add seeds inside a SeedFu::Writer#write block" unless @io
buffer = ''
if chunk_this_seed?
buffer << seed_footer
buffer << "# BREAK EVAL\n"
buffer << seed_header
end
buffer << ",\n"
buffer << ' ' + seed.inspect
@io.write(buffer)
@count += 1
end
alias_method :add, :<<
private
def write_to_io(io)
@io, @count = io, 0
@io.write(file_header)
@io.write(seed_header)
yield(self)
@io.write(seed_footer)
@io.write(file_footer)
ensure
@io, @count = nil, nil
end
def seed_header
constraints = @options[:constraints] && @options[:constraints].map(&:inspect).join(', ')
"#{@options[:class_name]}.#{@options[:seed_type]}(#{constraints}"
end
def seed_footer
"\n)\n"
end
個人的にはSeedFu::Writerでfixtureをジェネレートするよりは、元のデータをCSVなどに変換したものをデータソースとしてfixtureからそのデータソースを読み出して#seedを呼び出す方がfixtureのファイルの可読性やデータの再利用性の点で良いと思います。
補足
#seedには色々なパターンで引数を渡せるのですが、そこらへんをよしなにやってくれるのがSeedFu::ActiveRecordExtension#parse_seed_fu_argsです。例えば以下のようにconstraintsとブロックを渡してブロック内でパラメータを設定した場合を考えてみます。
User.seed(:id, :email) do |s|
s.id = 1
s.login = "jon"
s.email = "jon@example.com"
s.name = "Jon"
end
この場合は、#parse_seed_fu_argsは[args, [SeedFu::BlockHash.new(block).to_hash]]
が返ります。
SeedFu::BlockHashは以下のように定義されており、渡されたblockを自身を引数に呼び出します。block内はself.xxx=のアクセサメソッドで書き込まれるのでその度にmethod_missingが呼ばれて@hashに値が格納されます。こうすることでblock内での処理をhashに置き換えることができます。
module SeedFu
# @private
class BlockHash
def initialize(proc)
@hash = {}
proc.call(self)
end
def to_hash
@hash
end
def method_missing(method_name, *args, &block)
if method_name.to_s =~ /^(.*)=$/ && args.length == 1 && block.nil?
@hash[$1] = args.first
else
super
end
end
end
end
最終的にSeedFu::Seederのコンストラクタには self, args, ブロックから生成したハッシュの配列
が入ります。このようにしてconstraintsとハッシュ配列の生成に対応しています。