2018-02-20

seed-fuコードリーディング

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とハッシュ配列の生成に対応しています。

このエントリーをはてなブックマークに追加