lib/annotate/annotate_models.rb in annotate-2.7.2 vs lib/annotate/annotate_models.rb in annotate-2.7.3

- old
+ new

@@ -10,10 +10,12 @@ COMPAT_PREFIX_MD = '## Schema Info'.freeze PREFIX = '== Schema Information'.freeze PREFIX_MD = '## Schema Information'.freeze END_MARK = '== Schema Information End'.freeze + SKIP_ANNOTATION_PREFIX = '# -\*- SkipSchemaAnnotations'.freeze + MATCHED_TYPES = %w(test fixture factory serializer scaffold controller helper).freeze # File.join for windows reverse bar compat? # I dont use windows, can`t test UNIT_TEST_DIR = File.join('test', "unit") @@ -63,16 +65,31 @@ NO_LIMIT_COL_TYPES = %w(integer boolean).freeze # Don't show default value for these column types NO_DEFAULT_COL_TYPES = %w(json jsonb hstore).freeze + INDEX_CLAUSES = { + unique: { + default: 'UNIQUE', + markdown: '_unique_' + }, + where: { + default: 'WHERE', + markdown: '_where_' + }, + using: { + default: 'USING', + markdown: '_using_' + } + }.freeze + class << self def annotate_pattern(options = {}) if options[:wrapper_open] - return /(?:^\n?# (?:#{options[:wrapper_open]}).*\n?# (?:#{COMPAT_PREFIX}|#{COMPAT_PREFIX_MD}).*?\n(#.*\n)*\n*)|^\n?# (?:#{COMPAT_PREFIX}|#{COMPAT_PREFIX_MD}).*?\n(#.*\n)*\n*/ + return /(?:^(\n|\r\n)?# (?:#{options[:wrapper_open]}).*(\n|\r\n)?# (?:#{COMPAT_PREFIX}|#{COMPAT_PREFIX_MD}).*?(\n|\r\n)(#.*(\n|\r\n))*(\n|\r\n)*)|^(\n|\r\n)?# (?:#{COMPAT_PREFIX}|#{COMPAT_PREFIX_MD}).*?(\n|\r\n)(#.*(\n|\r\n))*(\n|\r\n)*/ end - /^\n?# (?:#{COMPAT_PREFIX}|#{COMPAT_PREFIX_MD}).*?\n(#.*\n)*\n*/ + /^(\n|\r\n)?# (?:#{COMPAT_PREFIX}|#{COMPAT_PREFIX_MD}).*?(\n|\r\n)(#.*(\n|\r\n))*(\n|\r\n)*/ end def model_dir @model_dir.is_a?(Array) ? @model_dir : [@model_dir || 'app/models'] end @@ -125,10 +142,12 @@ File.join(root_directory, BLUEPRINTS_SPEC_DIR, "%MODEL_NAME%_blueprint.rb"), File.join(root_directory, FACTORY_GIRL_TEST_DIR, "%MODEL_NAME%_factory.rb"), # (old style) File.join(root_directory, FACTORY_GIRL_SPEC_DIR, "%MODEL_NAME%_factory.rb"), # (old style) File.join(root_directory, FACTORY_GIRL_TEST_DIR, "%TABLE_NAME%.rb"), # (new style) File.join(root_directory, FACTORY_GIRL_SPEC_DIR, "%TABLE_NAME%.rb"), # (new style) + File.join(root_directory, FACTORY_GIRL_TEST_DIR, "%PLURALIZED_MODEL_NAME%.rb"), # (new style) + File.join(root_directory, FACTORY_GIRL_SPEC_DIR, "%PLURALIZED_MODEL_NAME%.rb"), # (new style) File.join(root_directory, FABRICATORS_TEST_DIR, "%MODEL_NAME%_fabricator.rb"), File.join(root_directory, FABRICATORS_SPEC_DIR, "%MODEL_NAME%_fabricator.rb") ] end @@ -193,27 +212,23 @@ indexes = klass.connection.indexes(table_name) return indexes if indexes.any? || !klass.table_name_prefix # Try to search the table without prefix - table_name.to_s.slice!(klass.table_name_prefix) - klass.connection.indexes(table_name) + table_name_without_prefix = table_name.to_s.sub(klass.table_name_prefix, '') + klass.connection.indexes(table_name_without_prefix) end # Use the column information in an ActiveRecord class # to create a comment block containing a line for # each column. The line contains the column name, # the type (and length), and any optional attributes def get_schema_info(klass, header, options = {}) info = "# #{header}\n" info << get_schema_header_text(klass, options) - max_size = klass.column_names.map(&:size).max || 0 - with_comment = options[:with_comment] && klass.columns.first.respond_to?(:comment) - max_size = klass.columns.map{|col| col.name.size + col.comment.size }.max || 0 if with_comment - max_size += 2 if with_comment - max_size += options[:format_rdoc] ? 5 : 1 + max_size = max_schema_info_width(klass, options) md_names_overhead = 6 md_type_allowance = 18 bare_type_allowance = 16 if options[:format_markdown] @@ -230,11 +245,11 @@ 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 + col_type = get_col_type(col) 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) @@ -272,11 +287,11 @@ ind = ind.columns.reject! { |i| i == col.name } attrs << (ind.empty? ? "indexed" : "indexed => [#{ind.join(", ")}]") end end end - col_name = if with_comment + col_name = if with_comments?(klass, options) && col.comment "#{col.name}(#{col.comment})" else col.name end if options[:format_rdoc] @@ -335,19 +350,87 @@ return '' if indexes.empty? max_size = indexes.collect{|index| index.name.size}.max + 1 indexes.sort_by(&:name).each do |index| index_info << if options[:format_markdown] - sprintf("# * `%s`%s:\n# * **`%s`**\n", index.name, index.unique ? " (_unique_)" : "", Array(index.columns).join("`**\n# * **`")) + final_index_string_in_markdown(index) else - sprintf("# %-#{max_size}.#{max_size}s %s %s", index.name, "(#{Array(index.columns).join(",")})", index.unique ? "UNIQUE" : "").rstrip + "\n" + final_index_string(index, max_size) end end index_info end + def get_col_type(col) + if col.respond_to?(:bigint?) && col.bigint? + 'bigint' + else + (col.type || col.sql_type).to_s + end + end + + def index_columns_info(index) + Array(index.columns).map do |col| + if index.try(:orders) && index.orders[col.to_s] + "#{col} #{index.orders[col.to_s].upcase}" + else + col.to_s.gsub("\r", '\r').gsub("\n", '\n') + end + end + end + + def index_unique_info(index, format = :default) + index.unique ? " #{INDEX_CLAUSES[:unique][format]}" : '' + end + + def index_where_info(index, format = :default) + value = index.try(:where).try(:to_s) + if value.blank? + '' + else + " #{INDEX_CLAUSES[:where][format]} #{value}" + end + end + + def index_using_info(index, format = :default) + value = index.try(:using) && index.using.try(:to_sym) + if !value.blank? && value != :btree + " #{INDEX_CLAUSES[:using][format]} #{value}" + else + '' + end + end + + def final_index_string_in_markdown(index) + details = sprintf( + "%s%s%s", + index_unique_info(index, :markdown), + index_where_info(index, :markdown), + index_using_info(index, :markdown) + ).strip + details = " (#{details})" unless details.blank? + + sprintf( + "# * `%s`%s:\n# * **`%s`**\n", + index.name, + details, + index_columns_info(index).join("`**\n# * **`") + ) + end + + def final_index_string(index, max_size) + sprintf( + "# %-#{max_size}.#{max_size}s %s%s%s%s", + index.name, + "(#{index_columns_info(index).join(',')})", + index_unique_info(index), + index_where_info(index), + index_using_info(index) + ).rstrip + "\n" + end + def hide_limit?(col_type, options) excludes = if options[:hide_limit_column_types].blank? NO_LIMIT_COL_TYPES else @@ -415,23 +498,22 @@ # :before, :top, :after or :bottom. Default is :before. # def annotate_one_file(file_name, info_block, position, options = {}) if File.exist?(file_name) old_content = File.read(file_name) - return false if old_content =~ /# -\*- SkipSchemaAnnotations.*\n/ + return false if old_content =~ /#{SKIP_ANNOTATION_PREFIX}.*\n/ # Ignore the Schema version line because it changes with each migration header_pattern = /(^# Table name:.*?\n(#.*[\r]?\n)*[\r]?)/ old_header = old_content.match(header_pattern).to_s new_header = info_block.match(header_pattern).to_s column_pattern = /^#[\t ]+[\w\*`]+[\t ]+.+$/ old_columns = old_header && old_header.scan(column_pattern).sort new_columns = new_header && new_header.scan(column_pattern).sort - magic_comment_matcher = Regexp.new(/(^#\s*encoding:.*\n)|(^# coding:.*\n)|(^# -\*- coding:.*\n)|(^# -\*- encoding\s?:.*\n)|(^#\s*frozen_string_literal:.+\n)|(^# -\*- frozen_string_literal\s*:.+-\*-\n)/) - magic_comments = old_content.scan(magic_comment_matcher).flatten.compact + magic_comments_block = magic_comments_as_string(old_content) if old_columns == new_columns && !options[:force] return false else # Replace inline the old schema info with the new schema info @@ -445,17 +527,17 @@ wrapper_close = options[:wrapper_close] ? "# #{options[:wrapper_close]}\n" : "" wrapped_info_block = "#{wrapper_open}#{info_block}#{wrapper_close}" # if there *was* no old schema info (no substitution happened) or :force was passed, # we simply need to insert it in correct position if new_content == old_content || options[:force] - old_content.sub!(magic_comment_matcher, '') + old_content.gsub!(magic_comment_matcher, '') old_content.sub!(annotate_pattern(options), '') new_content = if %w(after bottom).include?(options[position].to_s) - magic_comments.join + (old_content.rstrip + "\n\n" + wrapped_info_block) + magic_comments_block + (old_content.rstrip + "\n\n" + wrapped_info_block) else - magic_comments.join + wrapped_info_block + "\n" + old_content + magic_comments_block + wrapped_info_block + "\n" + old_content end end File.open(file_name, 'wb') { |f| f.puts new_content } return true @@ -463,13 +545,29 @@ else false end end + def magic_comment_matcher + Regexp.new(/(^#\s*encoding:.*(?:\n|r\n))|(^# coding:.*(?:\n|\r\n))|(^# -\*- coding:.*(?:\n|\r\n))|(^# -\*- encoding\s?:.*(?:\n|\r\n))|(^#\s*frozen_string_literal:.+(?:\n|\r\n))|(^# -\*- frozen_string_literal\s*:.+-\*-(?:\n|\r\n))/) + end + + def magic_comments_as_string(content) + magic_comments = content.scan(magic_comment_matcher).flatten.compact + + if magic_comments.any? + magic_comments.join + "\n" + else + '' + end + end + def remove_annotation_of_file(file_name, options = {}) if File.exist?(file_name) content = File.read(file_name) + return false if content =~ /#{SKIP_ANNOTATION_PREFIX}.*\n/ + wrapper_open = options[:wrapper_open] ? "# #{options[:wrapper_open]}\n" : '' content.sub!(/(#{wrapper_open})?#{annotate_pattern(options)}/, '') File.open(file_name, 'wb') { |f| f.puts content } @@ -540,12 +638,12 @@ annotated << f end end end rescue StandardError => e - puts "Unable to annotate #{file}: #{e.message}" - puts "\t" + e.backtrace.join("\n\t") if options[:trace] + $stderr.puts "Unable to annotate #{file}: #{e.message}" + $stderr.puts "\t" + e.backtrace.join("\n\t") if options[:trace] end annotated end @@ -557,79 +655,118 @@ # Return a list of the model files to annotate. # If we have command line arguments, they're assumed to the path # of model files from root dir. Otherwise we take all the model files # in the model_dir directory. def get_model_files(options) - models = [] - unless options[:is_rake] - models = ARGV.dup.reject { |m| m.match(/^(.*)=/) } - end + model_files = [] - 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 - puts "No models found in directory '#{model_dir.join("', '")}'." - puts "Either specify models on the command line, or use the --model-dir option." - puts "Call 'annotate --help' for more info." - exit 1 + model_files = list_model_files_from_argument unless options[:is_rake] + + return model_files unless model_files.empty? + + model_dir.each do |dir| + Dir.chdir(dir) do + list = if options[:ignore_model_sub_dir] + Dir["*.rb"].map { |f| [dir, f] } + else + Dir["**/*.rb"].reject { |f| f["concerns/"] }.map { |f| [dir, f] } + end + model_files.concat(list) end end - models + model_files + rescue SystemCallError + $stderr.puts "No models found in directory '#{model_dir.join("', '")}'." + $stderr.puts "Either specify models on the command line, or use the --model-dir option." + $stderr.puts "Call 'annotate --help' for more info." + exit 1 end + def list_model_files_from_argument + return [] if ARGV.empty? + + specified_files = ARGV.map { |file| File.expand_path(file) } + + model_files = model_dir.flat_map do |dir| + absolute_dir_path = File.expand_path(dir) + specified_files + .find_all { |file| file.start_with?(absolute_dir_path) } + .map { |file| [dir, file.sub("#{absolute_dir_path}/", '')] } + end + + if model_files.size != specified_files.size + puts "The specified file could not be found in directory '#{model_dir.join("', '")}'." + puts "Call 'annotate --help' for more info." + exit 1 + end + + model_files + end + private :list_model_files_from_argument + # Retrieve the classes belonging to the model names we're asked to process # Check for namespaced models in subdirectories as well as models # in subdirectories without namespacing. 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) + get_loaded_model(model_path, file) || raise(BadModelFileError.new) rescue LoadError # this is for non-rails projects, which don't get Rails auto-require magic file_path = File.expand_path(file) - if File.file?(file_path) && silence_warnings { Kernel.require(file_path) } + if File.file?(file_path) && Kernel.require(file_path) retry elsif model_path =~ /\// model_path = model_path.split('/')[1..-1].join('/').to_s retry else raise end end end + # Retrieve loaded model class + def get_loaded_model(model_path, file) + loaded_model_class = get_loaded_model_by_path(model_path) + return loaded_model_class if loaded_model_class + + # We cannot get loaded model when `model_path` is loaded by Rails + # auto_load/eager_load paths. Try all possible model paths one by one. + absolute_file = File.expand_path(file) + model_paths = + $LOAD_PATH.select { |path| absolute_file.include?(path) } + .map { |path| absolute_file.sub(path, '').sub(/\.rb$/, '').sub(/^\//, '') } + model_paths + .map { |path| get_loaded_model_by_path(path) } + .find { |loaded_model| !loaded_model.nil? } + end + # Retrieve loaded model class by path to the file where it's supposed to be defined. - def get_loaded_model(model_path) + def get_loaded_model_by_path(model_path) ActiveSupport::Inflector.constantize(ActiveSupport::Inflector.camelize(model_path)) - rescue + rescue StandardError, LoadError # Revert to the old way but it is not really robust ObjectSpace.each_object(::Class) .select do |c| Class === c && # note: we use === to avoid a bug in activesupport 2.3.14 OptionMerger vs. is_a? c.ancestors.respond_to?(:include?) && # to fix FactoryGirl bug, see https://github.com/ctran/annotate_models/pull/82 c.ancestors.include?(ActiveRecord::Base) end.detect { |c| ActiveSupport::Inflector.underscore(c.to_s) == model_path } end def parse_options(options = {}) - self.model_dir = options[:model_dir] if options[:model_dir] + self.model_dir = split_model_dir(options[:model_dir]) if options[:model_dir] self.root_dir = options[:root_dir] if options[:root_dir] end + def split_model_dir(option_value) + option_value = option_value.is_a?(Array) ? option_value : option_value.split(',') + option_value.map(&:strip).reject(&:empty?) + end + # We're passed a name of things that might be # ActiveRecord models. If we can find the class, and # if its a subclass of ActiveRecord::Base, # then pass it to the associated block def do_annotations(options = {}) @@ -653,27 +790,27 @@ end end def annotate_model_file(annotated, file, header, options) begin - return false if /# -\*- SkipSchemaAnnotations.*/ =~ (File.exist?(file) ? File.read(file) : '') + return false if /#{SKIP_ANNOTATION_PREFIX}.*/ =~ (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] + $stderr.puts "Unable to annotate #{file}: #{e.message}" + $stderr.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] + $stderr.puts "Unable to annotate #{file}: #{e.message}" + $stderr.puts "\t" + e.backtrace.join("\n\t") if options[:trace] end end def remove_annotations(options = {}) parse_options(options) @@ -699,12 +836,12 @@ end end end deannotated << klass if deannotated_klass rescue StandardError => e - puts "Unable to deannotate #{File.join(file)}: #{e.message}" - puts "\t" + e.backtrace.join("\n\t") if options[:trace] + $stderr.puts "Unable to deannotate #{File.join(file)}: #{e.message}" + $stderr.puts "\t" + e.backtrace.join("\n\t") if options[:trace] end end puts "Removed annotations from: #{deannotated.join(', ')}" end @@ -735,16 +872,29 @@ [rest_cols, timestamps, associations].each { |a| a.sort_by!(&:name) } ([id] << rest_cols << timestamps << associations).flatten.compact end - # Ignore warnings for the duration of the block () - def silence_warnings - old_verbose = $VERBOSE - $VERBOSE = nil - yield - ensure - $VERBOSE = old_verbose + private + + def with_comments?(klass, options) + options[:with_comment] && + klass.columns.first.respond_to?(:comment) && + klass.columns.any? { |col| !col.comment.nil? } + end + + def max_schema_info_width(klass, options) + if with_comments?(klass, options) + max_size = klass.columns.map do |column| + column.name.size + (column.comment ? column.comment.size : 0) + end.max || 0 + max_size += 2 + else + max_size = klass.column_names.map(&:size).max + end + max_size += options[:format_rdoc] ? 5 : 1 + + max_size end end class BadModelFileError < LoadError def to_s