2018-02-13

annotateコードリーディング

annotate (2.7.2)のコードリーディングをしました。

bin/annotateはRubyによる実行ファイルでOptionParserでオプション解析・取得しつつ AnnotateModels.do_annotations  や AnnotateRoutes.do_annotations を呼び出します。

require 'optparse'
require 'annotate'
Annotate.bootstrap_rake

has_set_position = {}
target_action = :do_annotations
positions = %w(before top after bottom)

OptionParser.new do |opts|
...
end.parse!

options = Annotate.setup_options(
  is_rake: ENV['is_rake'] && !ENV['is_rake'].empty?
)
Annotate.eager_load(options)

AnnotateModels.send(target_action, options) if Annotate.include_models?
AnnotateRoutes.send(target_action, options) if Annotate.include_routes?

Annotate.bootstrap_rakeではRakefileをロードしています。Railsの場合、Rakefileでconfig/application.rbを読み出すので、Railsがこの時点でロードされます。

def self.bootstrap_rake
  begin
    require 'rake/dsl_definition'
  rescue StandardError => e
    # We might just be on an old version of Rake...
    puts e.message
    exit e.status_code
  end
  require 'rake'

  load './Rakefile' if File.exist?('./Rakefile')
  begin
    Rake::Task[:environment].invoke
    binding.pry
  rescue
    nil
  end
  unless defined?(Rails)
    # Not in a Rails project, so time to load up the parts of
    # ActiveSupport we need.
    require 'active_support'
    require 'active_support/core_ext/class/subclasses'
    require 'active_support/core_ext/string/inflections'
  end

  load_tasks
  Rake::Task[:set_annotation_options].invoke
end

ただし、Railsアプリをロードするだけではモデルのファイルはロードされないので Annotate.eager_load経由でRails::Applicationのサブクラス(=実体のアプリケーションのクラス)の eager_load!メソッドを叩いてモデルファイルをロードしています。

def self.eager_load(options)
  load_requires(options)
  require 'annotate/active_record_patch'

  if defined?(Rails::Application)
    if Rails.version.split('.').first.to_i < 3
      Rails.configuration.eager_load_paths.each do |load_path|
        matcher = /\A#{Regexp.escape(load_path)}(.*)\.rb\Z/
        Dir.glob("#{load_path}/**/*.rb").sort.each do |file|
          require_dependency file.sub(matcher, '\1')
        end
      end
    else
      klass = Rails::Application.send(:subclasses).first
      klass.eager_load!
    end
  else
    options[:model_dir].each do |dir|
      FileList["#{dir}/**/*.rb"].each do |fname|
        require File.expand_path(fname)
      end
    end
  end
end

実際にアノテーションするのは AnnotateModels.do_annotationsの部分になります

module AnnotateModels
  class << self
    def do_annotations(options = {})
      parse_options(options)

      header = options[:format_markdown] ? PREFIX_MD.dup : PREFIX.dup
      version = ActiveRecord::Migrator.current_version rescue 0
      if options[:include_version] && version > 0
        header << "\n# Schema version: #{version}"
      end

      annotated = []
      get_model_files(options).each do |path, filename|
        annotate_model_file(annotated, File.join(path, filename), header, options)
      end

      if annotated.empty?
        puts 'Model files unchanged.'
      else
        puts "Annotated (#{annotated.length}): #{annotated.join(', ')}"
      end
    end

markdownでなければheader変数は # == Schema Information が入ります。get_model_filesで対象のモデルファイルを抽出しています。

def get_model_files(options)
  models = []

# ...
  if models.empty?
    begin
      model_dir.each do |dir|
        Dir.chdir(dir) do
          lst =
            if options[:ignore_model_sub_dir]
              Dir["*.rb"].map{ |f| [dir, f] }
            else
              Dir["**/*.rb"].reject{ |f| f["concerns/"] }.map{ |f| [dir, f] }
            end
          models.concat(lst)
        end
      end
    rescue SystemCallError
# ...
    end
  end

  models
end

def model_dir
  @model_dir.is_a?(Array) ? @model_dir : [@model_dir || 'app/models']
end

モデルのディレクトリからconernsを除いたファイルを [モデルディレクトリ, モデルディレクトリからのファイルパス] のArrayのArrayで返します。

ここで取得したファイルに対してannotate_model_fileでアノテーションしていきます。

def annotate_model_file(annotated, file, header, options)
  begin
    return false if /# -\*- SkipSchemaAnnotations.*/ =~ (File.exist?(file) ? File.read(file) : '')
    klass = get_model_class(file)
    do_annotate = klass &&
      klass < ActiveRecord::Base &&
      (!options[:exclude_sti_subclasses] || !(klass.superclass < ActiveRecord::Base && klass.table_name == klass.superclass.table_name)) &&
      !klass.abstract_class? &&
      klass.table_exists?

    annotated.concat(annotate(klass, file, header, options)) if do_annotate
  rescue BadModelFileError => e
    unless options[:ignore_unknown_models]
      puts "Unable to annotate #{file}: #{e.message}"
      puts "\t" + e.backtrace.join("\n\t") if options[:trace]
    end
  rescue StandardError => e
    puts "Unable to annotate #{file}: #{e.message}"
    puts "\t" + e.backtrace.join("\n\t") if options[:trace]
  end
end

get_model_classでファイル名からクラスをロードします。クラスのロードは ActiveSupport::Inflector.constantize で行っています。

def get_model_class(file)
  model_path = file.gsub(/\.rb$/, '')
  model_dir.each { |dir| model_path = model_path.gsub(/^#{dir}/, '').gsub(/^\//, '') }
  begin
    get_loaded_model(model_path) || raise(BadModelFileError.new)
  rescue LoadError
# ...
  end
end

# Retrieve loaded model class by path to the file where it's supposed to be defined.
def get_loaded_model(model_path)
  ActiveSupport::Inflector.constantize(ActiveSupport::Inflector.camelize(model_path))
rescue
# ...
end

annotateではモデルのクラスからスキーマ情報を取得して、annotate_one_fileメソッドを呼び出します。

def annotate(klass, file, header, options = {})
  begin
    klass.reset_column_information
    info = get_schema_info(klass, header, options)
    model_name = klass.name.underscore
    table_name = klass.table_name
    model_file_name = File.join(file)
    annotated = []

    if annotate_one_file(model_file_name, info, :position_in_class, options_with_position(options, :position_in_class))
      annotated << model_file_name
    end

# ...
  end

  annotated
end

annotate_one_fileでファイルの中身を変更しています。処理が長いので割愛しますが、パターンマッチと置換でinfo_blockのコメントをマジックコメントの下、モデル定義の上の部分に差し込みます。

実際に記述されるコメントはget_schema_infoで生成されます。ActiveRecord::Base.columnsでカラムを取得し、カラムの情報を元にコメントを生成します。こちらも処理が長いので一部割愛していますが全体としてはこんな感じです↓

def get_schema_info(klass, header, options = {})
  info = "# #{header}\n"
  info << get_schema_header_text(klass, options)

# ...

  cols = if ignore_columns = options[:ignore_columns]
           klass.columns.reject do |col|
             col.name.match(/#{ignore_columns}/)
           end
         else
           klass.columns
         end

  cols = cols.sort_by(&:name) if options[:sort]
  cols = classified_sort(cols) if options[:classified_sort]
  cols.each do |col|
    col_type = (col.type || col.sql_type).to_s
    attrs = []
    attrs << "default(#{schema_default(klass, col)})" unless col.default.nil? || hide_default?(col_type, options)
    attrs << 'unsigned' if col.respond_to?(:unsigned?) && col.unsigned?
    attrs << 'not null' unless col.null
    attrs << 'primary key' if klass.primary_key && (klass.primary_key.is_a?(Array) ? klass.primary_key.collect(&:to_sym).include?(col.name.to_sym) : col.name.to_sym == klass.primary_key.to_sym)

    if col_type == 'decimal'
      col_type << "(#{col.precision}, #{col.scale})"
    elsif col_type != 'spatial'
      if col.limit
        if col.limit.is_a? Array
          attrs << "(#{col.limit.join(', ')})"
        else
          col_type << "(#{col.limit})" unless hide_limit?(col_type, options)
        end
      end
    end

    # Check out if we got an array column
    attrs << 'is an Array' if col.respond_to?(:array) && col.array

# ...
    col_name = if with_comment
                 "#{col.name}(#{col.comment})"
               else
                 col.name
               end
    if options[:format_rdoc]
      info << sprintf("# %-#{max_size}.#{max_size}s<tt>%s</tt>", "*#{col_name}*::", attrs.unshift(col_type).join(", ")).rstrip + "\n"
    elsif options[:format_markdown]
      name_remainder = max_size - col_name.length
      type_remainder = (md_type_allowance - 2) - col_type.length
      info << (sprintf("# **`%s`**%#{name_remainder}s | `%s`%#{type_remainder}s | `%s`", col_name, " ", col_type, " ", attrs.join(", ").rstrip)).gsub('``', '  ').rstrip + "\n"
    else
      info << sprintf("#  %-#{max_size}.#{max_size}s:%-#{bare_type_allowance}.#{bare_type_allowance}s %s", col_name, col_type, attrs.join(", ")).rstrip + "\n"
    end
  end

  if options[:show_indexes] && klass.table_exists?
    info << get_index_info(klass, options)
  end

  if options[:show_foreign_keys] && klass.table_exists?
    info << get_foreign_key_info(klass, options)
  end

  info << get_schema_footer_text(klass, options)
end

ちなみにannotateは -rオプションを付けることでconfig/routes.rbにもアノテートできます。内容は rake routesの実行結果をヘッダに入れておりAnnotateRoutes.app_routes_mapで書き出すコメントをセットしています。

def self.app_routes_map(options)
  routes_map = `rake routes`.split(/\n/, -1)

  # In old versions of Rake, the first line of output was the cwd.  Not so
  # much in newer ones.  We ditch that line if it exists, and if not, we
  # keep the line around.
  routes_map.shift if routes_map.first =~ /^\(in \//

  # Skip routes which match given regex
  # Note: it matches the complete line (route_name, path, controller/action)
  if options[:ignore_routes]
    routes_map.reject! { |line| line =~ /#{options[:ignore_routes]}/ }
  end

  routes_map
end

 

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