Rails のGeneratorのデフォルト値はどこから来るのか

ことの発端

Rails のController generator で不要なrouting が書き込まれてしまうので、毎度--skip-routes オプションを付けるようにしている。 けれど、オプションを付け忘れることもあって、面倒なことになることがよくあった。 調べてみると、 config/application.rb などでconfig.generators を以下のように指定することでデフォルトのオプションとして指定できることがわかった。

config.generators do |g|
  g.skip_routes = true
end

該当の変更だけを見ても、仕組みが理解できなかったので調べてみた。

TL;DR

controller generator にオプションの渡し方は、以下の3通り。

  1. config.generators.controller = { skip_routes: true }
  2. config.generators.rails = { skip_routes: true }
  3. config.generators.skip_routes = true

個別のgeneratorのためのオプションとして最優先されるのは1の設定の仕方。 2と3は同等の扱いで、他のgeneratorとも共有される。

探求編

コードを読んでいく方針として次の順で行った。

  1. 参照側: ControllerGeneratoroptions[:skip_routes] はどこを見ているのか
  2. 設定側: Rails::Application.config.generators に指定した値はどこに入るのか
  3. 上記2つはどこでつながるのか

参照側

module Rails
  module Generators
    class ControllerGenerator < NamedBase # :nodoc:
      argument :actions, type: :array, default: [], banner: "action action"
      class_option :skip_routes, type: :boolean, desc: "Don't add routes to config/routes.rb."
     #
     # 中略
     #
      def add_routes
        return if options[:skip_routes]
        return if actions.empty?
        routing_code = actions.map { |action| "get '#{file_name}/#{action}'" }.join("\n")
        route routing_code, namespace: regular_class_path
      end

add_routes メソッド内で、options[:skip_routes] を見ているので、options がどこから来るのかを探る。 ControllerGenerator 自身は持っていないので、継承しているNamedBaseを見てみるがこれもoptionsを持っていない。更に上位クラスのRails::Generators::Baseを見ると、これがThor::Group を継承していることがわかる。 RailsのgeneratorなどCLIは、CLIを作成するDSLを提供するthorを使用している。 Thorのドキュメントを見ると、Thor::Group がincludeしているモジュールThor::Baseoptionsメソッドが存在していることがわかる。

ControllerGeneratorのコードを再度見ていくと、skip_routesオプションをclass_option で定義している。 これもまたthorに存在するメソッドである。しかし、デフォルト値を指定するためのdefaultをキーに持つハッシュが引数に渡されていない。 Rails::Generators::Base を見ると、class_option メソッドをオーバーライドしている箇所がある。

      def self.class_option(name, options = {}) #:nodoc:
        options[:desc]    = "Indicates when to generate #{name.to_s.humanize.downcase}" unless options.key?(:desc)
        options[:aliases] = default_aliases_for_option(name, options)
        options[:default] = default_value_for_option(name, options)
        super(name, options)
      end

これは、Thor::Base.class_optionsuperで呼ぶ前に、デフォルト値を設定している。 default_value_for_optionは以下のようになっている。

        def self.default_value_for_option(name, options) # :doc:
          default_for_option(Rails::Generators.options, name, options, options[:default])
        end

さらにdefault_for_optionを呼び出している。

        def self.default_for_option(config, name, options, default) # :doc:
          if generator_name && (c = config[generator_name.to_sym]) && c.key?(name)
            c[name]
          elsif base_name && (c = config[base_name.to_sym]) && c.key?(name)
            c[name]
          elsif config[:rails].key?(name)
            config[:rails][name]
          else
            default
          end
        end

これは以下のようにデフォルト値を決めている。

  1. config(ここだとRails::Generators.options)の中にgenerator名(ここだとcontroller)をキーとして持っていて、なおかつその値がハッシュでname(ここだとskip_routes)をキーとして持っていると、その値をデフォルト値とする
  2. config の中にbase_name(ここだとrails)をキーとして持っていて、なおかつその値がハッシュでnameをキーとして持っていると、その値をデフォルト値とする
  3. configの中にシンボルrailsをキーとして持っていて、なおかつその値がハッシュでありそのハッシュがnameをキーとして持っている場合、その値をデフォルト値とする
  4. 以上のどれにも当てはまらない場合、引数のdefaultをデフォルト値とする

つまり、Rails::Generators.options がどのようになっているのかで決まる。 この順番で先に見つかった値が使われる。

設定編

Rails Guideに、Generatorの設定についての記述があり、そこに下記のサンプルコードがある。

config.generators do |g|
  g.orm :active_record
  g.test_framework :test_unit
end

Rails::Engine::Configuration#generatorsを見ると、

      def generators
        @generators ||= Rails::Configuration::Generators.new
        yield(@generators) if block_given?
        @generators
      end

Rails::Configuration::Generatorsをブロックに渡している。 Rails::Configuration::Generatorsmethod_missing が定義されていて、任意の名前で設定値を与えることができる。 下記のように4通りの記述が可能である。

  1. config.generators.controller = { skip_routes: true }
  2. config.generators.controller skip_routes: true
  3. config.generators.rails = { skip_routes: true }
  4. config.generators.skip_routes = true

1、2は同じ扱いで、controller generator固有のオプションを設定している。 3、4もまた同じ扱いで、controller generatorのオプションのデフォルト値だけでなく他のgeneratorからも参照される可能性がある。 「可能性がある」というのは、先述のようにgenerator固有の設定(つまり上の1、2で指定された値)を優先するためである。

どこでつながるのか

これで一件落着のように感じるが、Rails::Generators.optionsの中を見ると、以下のようにDEFAULT_OPTIONSにあるハッシュを返しているだけである。

      def options #:nodoc:
        @options ||= DEFAULT_OPTIONS.dup
      end

rails generatorコマンド実行時にRails::Configuration::Generators に設定された内容をRails::Generators.options に入れている。

[https://github.com/rails/rails/blob/c0d91a4f9da10094ccdb80e34d1be42ce1016c9a/railties/lib/rails/commands/generate/generate_command.rb#L22:title]

このload_generatorsRails.application.load_generatorsを呼んでいるだけのメソッドである。 Rails.application.load_generators の実体はRails::Engine#load_generators で、この中で下記を実行している。

Rails::Generators.configure!(app.config.generators)

引数のapp.config.generators は、設定編で解説した設定で生成したRails::Generatorsオブジェクトである。 レシーバーのconfigure!の中で、Rails::Generators.optionsの値に、Rails::Configuration::Generators.optionsの値をmergeしている。

ということで、このように設定した値がController Generatorから参照されていることがわかった。 Generatorを自分で作成する必要がありそのGenerator固有のオプションを持つ場合、そのオプションのデフォルト値はconfig.generators.my_generator = { my_option: 'foobar' } といったように作成したgenerator名に値を与えることで設定できる。