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