lib/annotate/annotate_models.rb
# rubocop:disable Metrics/ModuleLength
require 'bigdecimal'
require 'annotate/constants'
require_relative 'annotate_models/file_patterns'
module AnnotateModels
# Annotate Models plugin use this header
COMPAT_PREFIX = '== Schema Info'.freeze
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
# Don't show limit (#) on these column types
# Example: show "integer" instead of "integer(4)"
NO_LIMIT_COL_TYPES = %w(integer bigint 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
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))/).freeze
class << self
def annotate_pattern(options = {})
if options[:wrapper_open]
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|\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
attr_writer :model_dir
def root_dir
if @root_dir.blank?
['']
elsif @root_dir.is_a?(String)
@root_dir.split(',')
else
@root_dir
end
end
attr_writer :root_dir
def skip_subdirectory_model_load
# This option is set in options[:skip_subdirectory_model_load]
# and stops the get_loaded_model method from loading a model from a subdir
if @skip_subdirectory_model_load.blank?
false
else
@skip_subdirectory_model_load
end
end
attr_writer :skip_subdirectory_model_load
def get_patterns(options, pattern_types = [])
current_patterns = []
root_dir.each do |root_directory|
Array(pattern_types).each do |pattern_type|
patterns = FilePatterns.generate(root_directory, pattern_type, options)
current_patterns += if pattern_type.to_sym == :additional_file_patterns
patterns
else
patterns.map { |p| p.sub(/^[\/]*/, '') }
end
end
end
current_patterns
end
# Simple quoting for the default column value
def quote(value)
case value
when NilClass then 'NULL'
when TrueClass then 'TRUE'
when FalseClass then 'FALSE'
when Float, Integer then value.to_s
# BigDecimals need to be output in a non-normalized form and quoted.
when BigDecimal then value.to_s('F')
when Array then value.map { |v| quote(v) }
else
value.inspect
end
end
def schema_default(klass, column)
quote(klass.column_defaults[column.name])
end
def retrieve_indexes_from_table(klass)
table_name = klass.table_name
return [] unless table_name
indexes = klass.connection.indexes(table_name)
return indexes if indexes.any? || !klass.table_name_prefix
# Try to search the table without prefix
table_name_without_prefix = table_name.to_s.sub(klass.table_name_prefix, '')
if klass.connection.table_exists?(table_name_without_prefix)
klass.connection.indexes(table_name_without_prefix)
else
[]
end
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 = {}) # rubocop:disable Metrics/MethodLength
info = "# #{header}\n"
info << get_schema_header_text(klass, options)
max_size = max_schema_info_width(klass, options)
md_names_overhead = 6
md_type_allowance = 18
bare_type_allowance = 16
if options[:format_markdown]
info << sprintf( "# %-#{max_size + md_names_overhead}.#{max_size + md_names_overhead}s | %-#{md_type_allowance}.#{md_type_allowance}s | %s\n", 'Name', 'Type', 'Attributes' )
info << "# #{ '-' * ( max_size + md_names_overhead ) } | #{'-' * md_type_allowance} | #{ '-' * 27 }\n"
end
cols = columns(klass, options)
with_comments = with_comments?(klass, options)
with_comments_column = with_comments_column?(klass, options)
# Precalculate Values
cols_meta = cols.map do |col|
col_comment = with_comments || with_comments_column ? col.comment&.gsub(/\n/, "\\n") : nil
col_type = get_col_type(col)
attrs = get_attributes(col, col_type, klass, options)
col_name = if with_comments && col_comment
"#{col.name}(#{col_comment})"
else
col.name
end
simple_formatted_attrs = attrs.join(", ")
[col.name, { col_type: col_type, attrs: attrs, col_name: col_name, simple_formatted_attrs: simple_formatted_attrs, col_comment: col_comment }]
end.to_h
# Output annotation
bare_max_attrs_length = cols_meta.map { |_, m| m[:simple_formatted_attrs].length }.max
cols.each do |col|
col_type = cols_meta[col.name][:col_type]
attrs = cols_meta[col.name][:attrs]
col_name = cols_meta[col.name][:col_name]
simple_formatted_attrs = cols_meta[col.name][:simple_formatted_attrs]
col_comment = cols_meta[col.name][:col_comment]
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_yard]
info << sprintf("# @!attribute #{col_name}") + "\n"
ruby_class = col.respond_to?(:array) && col.array ? "Array<#{map_col_type_to_ruby_classes(col_type)}>": map_col_type_to_ruby_classes(col_type)
info << sprintf("# @return [#{ruby_class}]") + "\n"
elsif options[:format_markdown]
name_remainder = max_size - col_name.length - non_ascii_length(col_name)
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"
elsif with_comments_column
info << format_default(col_name, max_size, col_type, bare_type_allowance, simple_formatted_attrs, bare_max_attrs_length, col_comment)
else
info << format_default(col_name, max_size, col_type, bare_type_allowance, simple_formatted_attrs)
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
if options[:show_check_constraints] && klass.table_exists?
info << get_check_constraint_info(klass, options)
end
info << get_schema_footer_text(klass, options)
end
def get_schema_header_text(klass, options = {})
info = "#\n"
if options[:format_markdown]
info << "# Table name: `#{klass.table_name}`\n"
info << "#\n"
info << "# ### Columns\n"
else
info << "# Table name: #{klass.table_name}\n"
end
info << "#\n"
end
def get_schema_footer_text(_klass, options = {})
info = ''
if options[:format_rdoc]
info << "#--\n"
info << "# #{END_MARK}\n"
info << "#++\n"
else
info << "#\n"
end
end
def get_index_info(klass, options = {})
index_info = if options[:format_markdown]
"#\n# ### Indexes\n#\n"
else
"#\n# Indexes\n#\n"
end
indexes = retrieve_indexes_from_table(klass)
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]
final_index_string_in_markdown(index)
else
final_index_string(index, max_size)
end
end
index_info
end
def get_col_type(col)
if (col.respond_to?(:bigint?) && col.bigint?) || /\Abigint\b/ =~ col.sql_type
'bigint'
else
(col.type || col.sql_type).to_s
end.dup
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
options[:hide_limit_column_types].split(',')
end
excludes.include?(col_type)
end
def hide_default?(col_type, options)
excludes =
if options[:hide_default_column_types].blank?
NO_DEFAULT_COL_TYPES
else
options[:hide_default_column_types].split(',')
end
excludes.include?(col_type)
end
def get_foreign_key_info(klass, options = {})
fk_info = if options[:format_markdown]
"#\n# ### Foreign Keys\n#\n"
else
"#\n# Foreign Keys\n#\n"
end
return '' unless klass.connection.respond_to?(:supports_foreign_keys?) &&
klass.connection.supports_foreign_keys? && klass.connection.respond_to?(:foreign_keys)
foreign_keys = klass.connection.foreign_keys(klass.table_name)
return '' if foreign_keys.empty?
format_name = lambda do |fk|
return fk.options[:column] if fk.name.blank?
options[:show_complete_foreign_keys] ? fk.name : fk.name.gsub(/(?<=^fk_rails_)[0-9a-f]{10}$/, '...')
end
max_size = foreign_keys.map(&format_name).map(&:size).max + 1
foreign_keys.sort_by {|fk| [format_name.call(fk), fk.column]}.each do |fk|
ref_info = "#{fk.column} => #{fk.to_table}.#{fk.primary_key}"
constraints_info = ''
constraints_info += "ON DELETE => #{fk.on_delete} " if fk.on_delete
constraints_info += "ON UPDATE => #{fk.on_update} " if fk.on_update
constraints_info.strip!
fk_info << if options[:format_markdown]
sprintf("# * `%s`%s:\n# * **`%s`**\n", format_name.call(fk), constraints_info.blank? ? '' : " (_#{constraints_info}_)", ref_info)
else
sprintf("# %-#{max_size}.#{max_size}s %s %s", format_name.call(fk), "(#{ref_info})", constraints_info).rstrip + "\n"
end
end
fk_info
end
def get_check_constraint_info(klass, options = {})
cc_info = if options[:format_markdown]
"#\n# ### Check Constraints\n#\n"
else
"#\n# Check Constraints\n#\n"
end
return '' unless klass.connection.respond_to?(:supports_check_constraints?) &&
klass.connection.supports_check_constraints? && klass.connection.respond_to?(:check_constraints)
check_constraints = klass.connection.check_constraints(klass.table_name)
return '' if check_constraints.empty?
max_size = check_constraints.map { |check_constraint| check_constraint.name.size }.max + 1
check_constraints.sort_by(&:name).each do |check_constraint|
expression = check_constraint.expression ? "(#{check_constraint.expression.squish})" : nil
cc_info << if options[:format_markdown]
cc_info_markdown = sprintf("# * `%s`", check_constraint.name)
cc_info_markdown << sprintf(": `%s`", expression) if expression
cc_info_markdown << "\n"
else
sprintf("# %-#{max_size}.#{max_size}s %s", check_constraint.name, expression).rstrip + "\n"
end
end
cc_info
end
# Add a schema block to a file. If the file already contains
# a schema info block (a comment starting with "== Schema Information"),
# check if it matches the block that is already there. If so, leave it be.
# If not, remove the old info block and write a new one.
#
# == Returns:
# true or false depending on whether the file was modified.
#
# === Options (opts)
# :force<Symbol>:: whether to update the file even if it doesn't seem to need it.
# :position_in_*<Symbol>:: where to place the annotated section in fixture or model file,
# :before, :top, :after or :bottom. Default is :before.
#
def annotate_one_file(file_name, info_block, position, options = {})
return false unless File.exist?(file_name)
old_content = File.read(file_name)
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
return false if old_columns == new_columns && !options[:force]
abort "annotate error. #{file_name} needs to be updated, but annotate was run with `--frozen`." if options[:frozen]
# Replace inline the old schema info with the new schema info
wrapper_open = options[:wrapper_open] ? "# #{options[:wrapper_open]}\n" : ""
wrapper_close = options[:wrapper_close] ? "# #{options[:wrapper_close]}\n" : ""
wrapped_info_block = "#{wrapper_open}#{info_block}#{wrapper_close}"
old_annotation = old_content.match(annotate_pattern(options)).to_s
# if there *was* no old schema info or :force was passed, we simply
# need to insert it in correct position
if old_annotation.empty? || options[:force]
magic_comments_block = magic_comments_as_string(old_content)
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_block + (old_content.rstrip + "\n\n" + wrapped_info_block)
elsif magic_comments_block.empty?
magic_comments_block + wrapped_info_block + old_content.lstrip
else
magic_comments_block + "\n" + wrapped_info_block + old_content.lstrip
end
else
# replace the old annotation with the new one
# keep the surrounding whitespace the same
space_match = old_annotation.match(/\A(?<start>\s*).*?\n(?<end>\s*)\z/m)
new_annotation = space_match[:start] + wrapped_info_block + space_match[:end]
new_content = old_content.sub(annotate_pattern(options), new_annotation)
end
File.open(file_name, 'wb') { |f| f.puts new_content }
true
end
def magic_comments_as_string(content)
magic_comments = content.scan(MAGIC_COMMENT_MATCHER).flatten.compact
if magic_comments.any?
magic_comments.join
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 }
true
else
false
end
end
def matched_types(options)
types = MATCHED_TYPES.dup
types << 'admin' if options[:active_admin] =~ Annotate::Constants::TRUE_RE && !types.include?('admin')
types << 'additional_file_patterns' if options[:additional_file_patterns].present?
types
end
# Given the name of an ActiveRecord class, create a schema
# info block (basically a comment containing information
# on the columns and their types) and put it at the front
# of the model and fixture source files.
#
# === Options (opts)
# :position_in_class<Symbol>:: where to place the annotated section in model file
# :position_in_test<Symbol>:: where to place the annotated section in test/spec file(s)
# :position_in_fixture<Symbol>:: where to place the annotated section in fixture file
# :position_in_factory<Symbol>:: where to place the annotated section in factory file
# :position_in_serializer<Symbol>:: where to place the annotated section in serializer file
# :exclude_tests<Symbol>:: whether to skip modification of test/spec files
# :exclude_fixtures<Symbol>:: whether to skip modification of fixture files
# :exclude_factories<Symbol>:: whether to skip modification of factory files
# :exclude_serializers<Symbol>:: whether to skip modification of serializer files
# :exclude_scaffolds<Symbol>:: whether to skip modification of scaffold files
# :exclude_controllers<Symbol>:: whether to skip modification of controller files
# :exclude_helpers<Symbol>:: whether to skip modification of helper files
# :exclude_sti_subclasses<Symbol>:: whether to skip modification of files for STI subclasses
#
# == Returns:
# an array of file names that were annotated.
#
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
matched_types(options).each do |key|
exclusion_key = "exclude_#{key.pluralize}".to_sym
position_key = "position_in_#{key}".to_sym
# Same options for active_admin models
if key == 'admin'
exclusion_key = 'exclude_class'.to_sym
position_key = 'position_in_class'.to_sym
end
next if options[exclusion_key]
get_patterns(options, key)
.map { |f| resolve_filename(f, model_name, table_name) }
.map { |f| expand_glob_into_files(f) }
.flatten
.each do |f|
if annotate_one_file(f, info, position_key, options_with_position(options, position_key))
annotated << f
end
end
end
rescue StandardError => e
$stderr.puts "Unable to annotate #{file}: #{e.message}"
$stderr.puts "\t" + e.backtrace.join("\n\t") if options[:trace]
end
annotated
end
# position = :position_in_fixture or :position_in_class
def options_with_position(options, position_in)
options.merge(position: (options[position_in] || options[:position]))
end
# 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)
model_files = []
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
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, 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) && 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)
unless skip_subdirectory_model_load
loaded_model_class = get_loaded_model_by_path(model_path)
return loaded_model_class if loaded_model_class
end
# 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.map(&:to_s)
.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_by_path(model_path)
ActiveSupport::Inflector.constantize(ActiveSupport::Inflector.camelize(model_path))
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 = split_model_dir(options[:model_dir]) if options[:model_dir]
self.root_dir = options[:root_dir] if options[:root_dir]
self.skip_subdirectory_model_load = options[:skip_subdirectory_model_load].present?
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 = {})
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
def expand_glob_into_files(glob)
Dir.glob(glob)
end
def annotate_model_file(annotated, file, header, options)
begin
return false if /#{SKIP_ANNOTATION_PREFIX}.*/ =~ (File.exist?(file) ? File.read(file) : '')
klass = get_model_class(file)
do_annotate = klass.is_a?(Class) &&
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]
$stderr.puts "Unable to annotate #{file}: #{e.message}"
$stderr.puts "\t" + e.backtrace.join("\n\t") if options[:trace]
end
rescue StandardError => e
$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)
deannotated = []
deannotated_klass = false
get_model_files(options).each do |file|
file = File.join(file)
begin
klass = get_model_class(file)
if klass < ActiveRecord::Base && !klass.abstract_class?
model_name = klass.name.underscore
table_name = klass.table_name
model_file_name = file
deannotated_klass = true if remove_annotation_of_file(model_file_name, options)
get_patterns(options, matched_types(options))
.map { |f| resolve_filename(f, model_name, table_name) }
.each do |f|
if File.exist?(f)
remove_annotation_of_file(f, options)
deannotated_klass = true
end
end
end
deannotated << klass if deannotated_klass
rescue StandardError => e
$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
def resolve_filename(filename_template, model_name, table_name)
filename_template
.gsub('%MODEL_NAME%', model_name)
.gsub('%PLURALIZED_MODEL_NAME%', model_name.pluralize)
.gsub('%TABLE_NAME%', table_name || model_name.pluralize)
end
def classified_sort(cols)
rest_cols = []
timestamps = []
associations = []
id = nil
cols.each do |c|
if c.name.eql?('id')
id = c
elsif c.name.eql?('created_at') || c.name.eql?('updated_at')
timestamps << c
elsif c.name[-3, 3].eql?('_id')
associations << c
else
rest_cols << c
end
end
[rest_cols, timestamps, associations].each { |a| a.sort_by!(&:name) }
([id] << rest_cols << timestamps << associations).flatten.compact
end
private
def with_comments?(klass, options)
options[:with_comment] &&
klass.columns.first.respond_to?(:comment) &&
klass.columns.any? { |col| !col.comment.nil? }
end
def with_comments_column?(klass, options)
options[:with_comment_column] &&
klass.columns.first.respond_to?(:comment) &&
klass.columns.any? { |col| !col.comment.nil? }
end
def max_schema_info_width(klass, options)
cols = columns(klass, options)
if with_comments?(klass, options)
max_size = cols.map do |column|
column.name.size + (column.comment ? width(column.comment) : 0)
end.max || 0
max_size += 2
else
max_size = cols.map(&:name).map(&:size).max
end
max_size += options[:format_rdoc] ? 5 : 1
max_size
end
# rubocop:disable Metrics/ParameterLists
def format_default(col_name, max_size, col_type, bare_type_allowance, simple_formatted_attrs, bare_max_attrs_length = 0, col_comment = nil)
sprintf(
"# %s:%s %s %s",
mb_chars_ljust(col_name, max_size),
mb_chars_ljust(col_type, bare_type_allowance),
mb_chars_ljust(simple_formatted_attrs, bare_max_attrs_length),
col_comment
).rstrip + "\n"
end
def width(string)
string.chars.inject(0) { |acc, elem| acc + (elem.bytesize == 3 ? 2 : 1) }
end
def mb_chars_ljust(string, length)
string = string.to_s
padding = length - width(string)
if padding > 0
string + (' ' * padding)
else
string[0..length-1]
end
end
def non_ascii_length(string)
string.to_s.chars.reject(&:ascii_only?).length
end
def map_col_type_to_ruby_classes(col_type)
case col_type
when 'integer' then Integer.to_s
when 'float' then Float.to_s
when 'decimal' then BigDecimal.to_s
when 'datetime', 'timestamp', 'time' then Time.to_s
when 'date' then Date.to_s
when 'text', 'string', 'binary', 'inet', 'uuid' then String.to_s
when 'json', 'jsonb' then Hash.to_s
when 'boolean' then 'Boolean'
end
end
def columns(klass, options)
cols = klass.columns
cols += translated_columns(klass)
if ignore_columns = options[:ignore_columns]
cols = cols.reject do |col|
col.name.match(/#{ignore_columns}/)
end
end
cols = cols.sort_by(&:name) if options[:sort]
cols = classified_sort(cols) if options[:classified_sort]
cols
end
##
# Add columns managed by the globalize gem if this gem is being used.
def translated_columns(klass)
return [] unless klass.respond_to? :translation_class
ignored_cols = ignored_translation_table_colums(klass)
klass.translation_class.columns.reject do |col|
ignored_cols.include? col.name.to_sym
end
end
##
# These are the columns that the globalize gem needs to work but
# are not necessary for the models to be displayed as annotations.
def ignored_translation_table_colums(klass)
# Construct the foreign column name in the translations table
# eg. Model: Car, foreign column name: car_id
foreign_column_name = [
klass.table_name.to_s.singularize,
'_id'
].join.to_sym
[
:id,
:created_at,
:updated_at,
:locale,
foreign_column_name
]
end
##
# Get the list of attributes that should be included in the annotation for
# a given column.
def get_attributes(column, column_type, klass, options)
attrs = []
attrs << "default(#{schema_default(klass, column)})" unless column.default.nil? || hide_default?(column_type, options)
attrs << 'unsigned' if column.respond_to?(:unsigned?) && column.unsigned?
attrs << 'not null' unless column.null
attrs << 'primary key' if klass.primary_key && (klass.primary_key.is_a?(Array) ? klass.primary_key.collect(&:to_sym).include?(column.name.to_sym) : column.name.to_sym == klass.primary_key.to_sym)
if column_type == 'decimal'
column_type << "(#{column.precision}, #{column.scale})"
elsif !%w[spatial geometry geography].include?(column_type)
if column.limit && !options[:format_yard]
if column.limit.is_a? Array
attrs << "(#{column.limit.join(', ')})"
else
column_type << "(#{column.limit})" unless hide_limit?(column_type, options)
end
end
end
# Check out if we got an array column
attrs << 'is an Array' if column.respond_to?(:array) && column.array
# Check out if we got a geometric column
# and print the type and SRID
if column.respond_to?(:geometry_type)
attrs << [column.geometry_type, column.try(:srid)].compact.join(', ')
elsif column.respond_to?(:geometric_type) && column.geometric_type.present?
attrs << [column.geometric_type.to_s.downcase, column.try(:srid)].compact.join(', ')
end
# Check if the column has indices and print "indexed" if true
# If the index includes another column, print it too.
if options[:simple_indexes] && klass.table_exists?# Check out if this column is indexed
indices = retrieve_indexes_from_table(klass)
if indices = indices.select { |ind| ind.columns.include? column.name }
indices.sort_by(&:name).each do |ind|
next if ind.columns.is_a?(String)
ind = ind.columns.reject! { |i| i == column.name }
attrs << (ind.empty? ? "indexed" : "indexed => [#{ind.join(", ")}]")
end
end
end
attrs
end
end
class BadModelFileError < LoadError
def to_s
"file doesn't contain a valid model class"
end
end
end