Commit 89bce57f by cuong.tran

Merge branch 'release/v2.7.3'

parents 5f37a974 db3eb14c
# This configuration was generated by
# `rubocop --auto-gen-config`
# on 2016-12-17 10:16:05 +0100 using RuboCop version 0.46.0.
# on 2017-10-13 10:01:18 +0200 using RuboCop version 0.46.0.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
......@@ -65,11 +65,6 @@ Lint/HandleExceptions:
Exclude:
- 'bin/annotate'
# Offense count: 8
Lint/IneffectiveAccessModifier:
Exclude:
- 'lib/annotate/annotate_routes.rb'
# Offense count: 1
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles.
......@@ -88,62 +83,51 @@ Lint/ShadowingOuterLocalVariable:
Exclude:
- 'Rakefile'
# Offense count: 6
# Offense count: 7
# Cop supports --auto-correct.
# Configuration parameters: IgnoreEmptyBlocks, AllowUnusedKeywordArguments.
Lint/UnusedBlockArgument:
Exclude:
- 'bin/annotate'
# Offense count: 1
# Configuration parameters: ContextCreatingMethods.
Lint/UselessAccessModifier:
Exclude:
- 'lib/annotate/annotate_routes.rb'
# Offense count: 17
# Offense count: 18
Metrics/AbcSize:
Max: 159
Max: 142
# Offense count: 3
# Configuration parameters: CountComments.
Metrics/BlockLength:
Max: 134
Max: 142
# Offense count: 2
Metrics/BlockNesting:
Max: 4
# Offense count: 8
# Offense count: 9
Metrics/CyclomaticComplexity:
Max: 42
Max: 36
# Offense count: 350
# Offense count: 380
# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns.
# URISchemes: http, https
Metrics/LineLength:
Max: 543
Max: 276
# Offense count: 24
# Offense count: 26
# Configuration parameters: CountComments.
Metrics/MethodLength:
Max: 79
# Offense count: 1
# Configuration parameters: CountComments.
Metrics/ModuleLength:
Max: 116
Max: 75
# Offense count: 7
Metrics/PerceivedComplexity:
Max: 48
Max: 42
# Offense count: 1
Style/AccessorMethodName:
Exclude:
- 'lib/annotate.rb'
# Offense count: 3
# Offense count: 2
# Cop supports --auto-correct.
Style/AlignArray:
Exclude:
......@@ -259,7 +243,7 @@ Style/ExtraSpacing:
- 'spec/integration/rails_4.2.0/Gemfile'
- 'spec/integration/rails_4.2.0/config.ru'
# Offense count: 9
# Offense count: 10
# Configuration parameters: EnforcedStyle, SupportedStyles.
# SupportedStyles: format, sprintf, percent
Style/FormatString:
......@@ -267,13 +251,6 @@ Style/FormatString:
- 'lib/annotate/annotate_models.rb'
- 'lib/annotate/annotate_routes.rb'
# Offense count: 181
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles.
# SupportedStyles: when_needed, always
Style/FrozenStringLiteralComment:
Enabled: false
# Offense count: 7
# Configuration parameters: MinBodyLength.
Style/GuardClause:
......@@ -356,16 +333,6 @@ Style/NumericLiterals:
# Offense count: 2
# Cop supports --auto-correct.
# Configuration parameters: AutoCorrect, EnforcedStyle, SupportedStyles.
# SupportedStyles: predicate, comparison
Style/NumericPredicate:
Exclude:
- 'spec/**/*'
- 'lib/annotate.rb'
- 'lib/annotate/annotate_models.rb'
# Offense count: 6
# Cop supports --auto-correct.
# Configuration parameters: PreferredDelimiters.
Style/PercentLiteralDelimiters:
Exclude:
......@@ -458,16 +425,15 @@ Style/SpaceAroundKeyword:
- 'spec/integration/rails_4.2.0/Gemfile'
- 'spec/integration/standalone/Gemfile'
# Offense count: 6
# Offense count: 4
# Cop supports --auto-correct.
# Configuration parameters: AllowForAlignment.
Style/SpaceAroundOperators:
Exclude:
- 'lib/annotate/annotate_models.rb'
- 'lib/tasks/annotate_models.rake'
- 'lib/tasks/annotate_routes.rake'
# Offense count: 4
# Offense count: 1
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles.
# SupportedStyles: space, no_space
......@@ -517,14 +483,14 @@ Style/SpaceInsideStringInterpolation:
Exclude:
- 'lib/annotate/annotate_models.rb'
# Offense count: 223
# Offense count: 237
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles, ConsistentQuotesInMultiline.
# SupportedStyles: single_quotes, double_quotes
Style/StringLiterals:
Enabled: false
# Offense count: 2
# Offense count: 1
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle, SupportedStyles.
# SupportedStyles: single_quotes, double_quotes
......@@ -581,7 +547,7 @@ Style/UnneededInterpolation:
Exclude:
- 'bin/annotate'
# Offense count: 8
# Offense count: 4
# Cop supports --auto-correct.
Style/UnneededPercentQ:
Exclude:
......
sudo: false
language: ruby
rvm:
- 1.9.3
- 2.0
- 2.1
- 2.2.5
- 2.3.0
- 2.4.0
- ruby-head
- 2.2.7
- 2.3.4
- 2.4.1
- ruby-head
matrix:
allow_failures:
- rvm: ruby-head
- rvm: 1.9.3
- rvm: ruby-head
before_install:
- gem update --system
- gem update bundler
- gem update --system
- gem update bundler
script:
- bundle exec rubocop && bundle exec rspec
- bundle exec rubocop && bundle exec rspec
deploy:
provider: rubygems
api_key:
secure: Y7DUitak26kcRAAkgph/7m6Y1wHeObD0BelSSJbmCfjkRd/qaVy7fz9VvHL9zxlRJtLGVHInyCnwcfzinibY6OFd3MoMYHKv8GFa2LxLJNEVSY46KQYFxfH5JTg1ejh6ldoJRRBoeOx9dcWS80pRNjYMKPGnpSz7yDBl1azibFs=
gem: annotate
on:
tags: true
repo: ctran/annotate_models
== 2.7.3
See https://github.com/ctran/annotate_models/releases/tag/v2.7.3
== 2.7.2
See https://github.com/ctran/annotate_models/releases/tag/v2.7.2
......
source 'https://rubygems.org'
ruby '>= 2.2.0'
gem 'activerecord', '>= 4.2.5', require: false
gem 'rake', require: false
group :development do
gem 'bump'
gem 'mg', require: false
gem 'travis', require: false
platforms :mri, :mingw do
gem 'yard', require: false
end
......
......@@ -17,7 +17,7 @@ your...
- Object Daddy exemplars
- Machinist blueprints
- Fabrication fabricators
- Thoughtbot's factory_girl factories, i.e. the (spec|test)/factories/<model>_factory.rb files
- Thoughtbot's factory_bot factories, i.e. the (spec|test)/factories/<model>_factory.rb files
- routes.rb file (for Rails projects)
The schema comment looks like this:
......@@ -55,11 +55,15 @@ Also, if you pass the -r option, it'll annotate routes.rb with the output of
Into Gemfile from rubygems.org:
gem 'annotate'
group :development do
gem 'annotate'
end
Into Gemfile from Github:
gem 'annotate', git: 'https://github.com/ctran/annotate_models.git'
group :development do
gem 'annotate', git: 'https://github.com/ctran/annotate_models.git'
end
Into environment gems from rubygems.org:
......@@ -154,14 +158,15 @@ If you want to run <code>rake db:migrate</code> as a one-off without running ann
you can do so with a simple environment variable, instead of editing the
+.rake+ file:
skip_on_db_migrate=1 rake db:migrate
ANNOTATE_SKIP_ON_DB_MIGRATE=1 rake db:migrate
== Options
Usage: annotate [options] [model_file]*
-d, --delete Remove annotations from all model files or the routes.rb file
-p, --position [before|top|after|bottom] Place the annotations at the top (before) or the bottom (after) of the model/test/fixture/factory/routes file(s)
-p [before|top|after|bottom], Place the annotations at the top (before) or the bottom (after) of the model/test/fixture/factory/route/serializer file(s)
--position
--pc, --position-in-class [before|top|after|bottom]
Place the annotations at the top (before) or the bottom (after) of the model file
--pf, --position-in-factory [before|top|after|bottom]
......@@ -179,15 +184,19 @@ you can do so with a simple environment variable, instead of editing the
--wo, --wrapper-open STR Annotation wrapper opening.
--wc, --wrapper-close STR Annotation wrapper closing
-r, --routes Annotate routes.rb with the output of 'rake routes'
-aa, --active-admin Annotate all activeadmin models
-a, --active-admin Annotate active_admin models
-v, --version Show the current version of this gem
-m, --show-migration Include the migration version number in the annotation
-i, --show-indexes List the table's database indexes in the annotation
-k, --show-foreign-keys List the table's foreign key constraints in the annotation
--ck, --complete-foreign-keys
Complete foreign key names in the annotation
-i, --show-indexes List the table's database indexes in the annotation
-s, --simple-indexes Concat the column's related indexes in the annotation
--model-dir dir Annotate model files stored in dir rather than app/models, separate multiple dirs with commas
--root-dir dir Annotate files stored within root dir projects, separate multiple dirs with commas
--ignore-model-subdirects Ignore subdirectories of the models directory
--sort Sort columns alphabetically, rather than in creation order
--classified-sort Sort columns alphabetically, but first goes id, then the rest columns, then the timestamp columns and then the association columns
-R, --require path Additional file to require before loading models, may be used multiple times
-e [tests,fixtures,factories,serializers],
--exclude Do not annotate fixtures, test files, factories, and/or serializers
......@@ -197,6 +206,13 @@ you can do so with a simple environment variable, instead of editing the
--timestamp Include timestamp in (routes) annotation
--trace If unable to annotate a file, print the full stack trace, not just the exception message.
-I, --ignore-columns REGEX don't annotate columns that match a given REGEX (i.e., `annotate -I '^(id|updated_at|created_at)'`
--ignore-routes REGEX don't annotate routes that match a given REGEX (i.e., `annotate -I '(mobile|resque|pghero)'`
--hide-limit-column-types VALUES
don't show limit for given column types, separated by commas (i.e., `integer,boolean,text`)
--hide-default-column-types VALUES
don't show default for given column types, separated by commas (i.e., `json,jsonb,hstore`)
--ignore-unknown-models don't display warnings for bad model files
--with-comment include database comments in model annotations
......@@ -242,7 +258,7 @@ extra carefully, and consider using one.
== Links
- Factory Girl: http://github.com/thoughtbot/factory_girl
- Factory Bot: http://github.com/thoughtbot/factory_bot
- Object Daddy: http://github.com/flogic/object_daddy
- Machinist: http://github.com/notahat/machinist
- Fabrication: http://github.com/paulelliott/fabrication
......
......@@ -28,7 +28,7 @@ require 'mg'
begin
MG.new('annotate.gemspec')
rescue Exception
STDERR.puts("WARNING: Couldn't read gemspec. As such, a number of tasks may be unavailable to you until you run 'rake gem:gemspec' to correct the issue.")
$stderr.puts("WARNING: Couldn't read gemspec. As such, a number of tasks may be unavailable to you until you run 'rake gem:gemspec' to correct the issue.")
# Gemspec is probably in a broken state, so let's give ourselves a chance to
# build a new one...
end
......
......@@ -7,7 +7,7 @@ Gem::Specification.new do |s|
s.name = 'annotate'
s.version = Annotate.version
s.required_ruby_version = '>= 1.9.3'
s.required_ruby_version = '>= 2.2.0'
s.required_rubygems_version = Gem::Requirement.new('>= 0') if s.respond_to? :required_rubygems_version=
s.authors = ['Alex Chaffee', 'Cuong Tran', 'Marcos Piccinini', 'Turadg Aleahmad', 'Jon Frisby']
s.description = 'Annotates Rails/ActiveRecord Models, routes, fixtures, and others based on the database schema.'
......
......@@ -90,7 +90,7 @@ OptionParser.new do |opts|
ENV['routes'] = 'true'
end
opts.on('-aa', '--active-admin', 'Annotate active_admin models') do
opts.on('-a', '--active-admin', 'Annotate active_admin models') do
ENV['active_admin'] = 'true'
end
......@@ -107,6 +107,12 @@ OptionParser.new do |opts|
ENV['show_foreign_keys'] = 'yes'
end
opts.on('--ck',
'--complete-foreign-keys', 'Complete foreign key names in the annotation') do
ENV['show_foreign_keys'] = 'yes'
ENV['show_complete_foreign_keys'] = 'yes'
end
opts.on('-i', '--show-indexes',
"List the table's database indexes in the annotation") do
ENV['show_indexes'] = 'yes'
......@@ -191,12 +197,16 @@ OptionParser.new do |opts|
opts.on('--ignore-unknown-models', "don't display warnings for bad model files") do |values|
ENV['ignore_unknown_models'] = 'true'
end
opts.on('--with-comment', "include database comments in model annotations") do |values|
ENV['with_comment'] = 'true'
end
end.parse!
options = Annotate.setup_options(
is_rake: ENV['is_rake'] && !ENV['is_rake'].empty?
)
Annotate.eager_load(options)
Annotate.eager_load(options) if Annotate.include_models?
AnnotateModels.send(target_action, options) if Annotate.include_models?
AnnotateRoutes.send(target_action, options) if Annotate.include_routes?
......@@ -30,9 +30,10 @@ module Annotate
:show_indexes, :simple_indexes, :include_version, :exclude_tests,
:exclude_fixtures, :exclude_factories, :ignore_model_sub_dir,
:format_bare, :format_rdoc, :format_markdown, :sort, :force, :trace,
:timestamp, :exclude_serializers, :classified_sort, :show_foreign_keys,
:timestamp, :exclude_serializers, :classified_sort,
:show_foreign_keys, :show_complete_foreign_keys,
:exclude_scaffolds, :exclude_controllers, :exclude_helpers,
:exclude_sti_subclasses, :ignore_unknown_models
:exclude_sti_subclasses, :ignore_unknown_models, :with_comment
].freeze
OTHER_OPTIONS = [
:ignore_columns, :skip_on_db_migrate, :wrapper_open, :wrapper_close,
......@@ -105,7 +106,7 @@ module Annotate
end
def self.skip_on_migration?
ENV['skip_on_db_migrate'] =~ TRUE_RE
ENV['ANNOTATE_SKIP_ON_DB_MIGRATE'] =~ TRUE_RE || ENV['skip_on_db_migrate'] =~ TRUE_RE
end
def self.include_routes?
......@@ -113,7 +114,7 @@ module Annotate
end
def self.include_models?
true
ENV['routes'] !~ TRUE_RE
end
def self.loaded_tasks=(val)
......@@ -168,7 +169,7 @@ module Annotate
require 'rake/dsl_definition'
rescue StandardError => e
# We might just be on an old version of Rake...
puts e.message
$stderr.puts e.message
exit e.status_code
end
require 'rake'
......
......@@ -12,6 +12,8 @@ module AnnotateModels
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?
......@@ -65,12 +67,27 @@ module AnnotateModels
# 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
......@@ -127,6 +144,8 @@ module AnnotateModels
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")
]
......@@ -195,8 +214,8 @@ module AnnotateModels
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
......@@ -207,11 +226,7 @@ module AnnotateModels
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
......@@ -232,7 +247,7 @@ module AnnotateModels
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?
......@@ -274,7 +289,7 @@ module AnnotateModels
end
end
end
col_name = if with_comment
col_name = if with_comments?(klass, options) && col.comment
"#{col.name}(#{col.comment})"
else
col.name
......@@ -337,15 +352,83 @@ module AnnotateModels
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?
......@@ -417,7 +500,7 @@ module AnnotateModels
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]?)/
......@@ -428,8 +511,7 @@ module AnnotateModels
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
......@@ -447,13 +529,13 @@ module AnnotateModels
# 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
......@@ -465,9 +547,25 @@ module AnnotateModels
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)}/, '')
......@@ -542,8 +640,8 @@ module AnnotateModels
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
......@@ -559,35 +657,53 @@ module AnnotateModels
# 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.
......@@ -595,11 +711,11 @@ module AnnotateModels
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
......@@ -610,10 +726,26 @@ module AnnotateModels
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|
......@@ -624,10 +756,15 @@ module AnnotateModels
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,
......@@ -655,7 +792,7 @@ module AnnotateModels
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 &&
......@@ -666,12 +803,12 @@ module AnnotateModels
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
......@@ -701,8 +838,8 @@ module AnnotateModels
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(', ')}"
......@@ -737,13 +874,26 @@ module AnnotateModels
([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
......
# rubocop:disable Metrics/ModuleLength
# == Annotate Routes
#
# Based on:
......@@ -36,7 +38,18 @@ module AnnotateRoutes
def header(options = {})
routes_map = app_routes_map(options)
out = ["# #{options[:format_markdown] ? PREFIX_MD : PREFIX}" + (options[:timestamp] ? " (Updated #{Time.now.strftime('%Y-%m-%d %H:%M')})" : '')]
magic_comments_map, routes_map = extract_magic_comments_from_array(routes_map)
out = []
magic_comments_map.each do |magic_comment|
out << magic_comment
end
out << '' if magic_comments_map.any?
out += ["# #{options[:wrapper_open]}"] if options[:wrapper_open]
out += ["# #{options[:format_markdown] ? PREFIX_MD : PREFIX}" + (options[:timestamp] ? " (Updated #{Time.now.strftime('%Y-%m-%d %H:%M')})" : '')]
out += ['#']
return out if routes_map.size.zero?
......@@ -51,35 +64,56 @@ module AnnotateRoutes
out += ["# #{content(routes_map[0], maxs, options)}"]
end
out + routes_map[1..-1].map { |line| "# #{content(options[:format_markdown] ? line.split(' ') : line, maxs, options)}" }
out += routes_map[1..-1].map { |line| "# #{content(options[:format_markdown] ? line.split(' ') : line, maxs, options)}" }
out += ["# #{options[:wrapper_close]}"] if options[:wrapper_close]
out
end
def do_annotations(options = {})
return unless routes_exists?
existing_text = File.read(routes_file)
if write_contents(existing_text, header(options), options)
if rewrite_contents_with_header(existing_text, header(options), options)
puts "#{routes_file} annotated."
end
end
def remove_annotations(options={})
def remove_annotations(_options={})
return unless routes_exists?
existing_text = File.read(routes_file)
content, where_header_found = strip_annotations(existing_text)
content = strip_on_removal(content, where_header_found)
if write_contents(existing_text, content, options)
new_content = strip_on_removal(content, where_header_found)
if rewrite_contents(existing_text, new_content)
puts "Removed annotations from #{routes_file}."
end
end
end
private
def self.magic_comment_matcher
Regexp.new(/(^#\s*encoding:.*)|(^# coding:.*)|(^# -\*- coding:.*)|(^# -\*- encoding\s?:.*)|(^#\s*frozen_string_literal:.+)|(^# -\*- frozen_string_literal\s*:.+-\*-)/)
end
# @param [Array<String>] content
# @return [Array<String>] all found magic comments
# @return [Array<String>] content without magic comments
def self.extract_magic_comments_from_array(content_array)
magic_comments = []
new_content = []
content_array.map do |row|
if row =~ magic_comment_matcher
magic_comments << row.strip
else
new_content << row
end
end
[magic_comments, new_content]
end
def self.app_routes_map(options)
routes_map = `rake routes`.split(/\n/, -1)
routes_map = `rake routes`.chomp("\n").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
......@@ -106,7 +140,22 @@ module AnnotateRoutes
routes_exists
end
def self.write_contents(existing_text, header, options = {})
# @param [String, Array<String>]
def self.rewrite_contents(existing_text, new_content)
# Make sure we end on a trailing newline.
new_content << '' unless new_content.last == ''
new_text = new_content.join("\n")
if existing_text == new_text
puts "#{routes_file} unchanged."
false
else
File.open(routes_file, 'wb') { |f| f.puts(new_text) }
true
end
end
def self.rewrite_contents_with_header(existing_text, header, options = {})
content, where_header_found = strip_annotations(existing_text)
new_content = annotate_routes(header, content, where_header_found, options)
......@@ -124,9 +173,11 @@ module AnnotateRoutes
end
def self.annotate_routes(header, content, where_header_found, options = {})
magic_comments_map, content = extract_magic_comments_from_array(content)
if %w(before top).include?(options[:position_in_routes])
header = header << '' if content.first != ''
new_content = header + content
magic_comments_map << '' if magic_comments_map.any?
new_content = magic_comments_map + header + content
else
# Ensure we have adequate trailing newlines at the end of the file to
# ensure a blank line separating the content from the annotation.
......@@ -136,7 +187,7 @@ module AnnotateRoutes
# the spacer we put in the first time around.
content.shift if where_header_found == :before && content.first == ''
new_content = content + header
new_content = magic_comments_map + content + header
end
new_content
......@@ -156,7 +207,7 @@ module AnnotateRoutes
content.split(/\n/, -1).each_with_index do |line, line_number|
if mode == :header && line !~ /\s*#/
mode = :content
next unless line == ''
real_content << line unless line.blank?
elsif mode == :content
if line =~ /^\s*#\s*== Route.*$/
header_found_at = line_number + 1 # index start's at 0
......
module Annotate
def self.version
'2.7.2'
'2.7.3'
end
end
require 'annotate'
module Annotate
module Generators
class InstallGenerator < Rails::Generators::Base
desc 'Copy annotate_models rakefiles for automatic annotation'
source_root File.expand_path('../templates', __FILE__)
source_root File.expand_path('templates', __dir__)
# copy rake tasks
def copy_tasks
......
......@@ -42,6 +42,7 @@ if Rails.env.development?
'format_markdown' => 'false',
'sort' => 'false',
'force' => 'false',
'classified_sort' => 'true',
'trace' => 'false',
'wrapper_open' => nil,
'wrapper_close' => nil,
......
......@@ -47,6 +47,7 @@ task annotate_models: :environment do
options[:ignore_routes] = ENV.fetch('ignore_routes', nil)
options[:hide_limit_column_types] = Annotate.fallback(ENV['hide_limit_column_types'], '')
options[:hide_default_column_types] = Annotate.fallback(ENV['hide_default_column_types'], '')
options[:with_comment] = Annotate.fallback(ENV['with_comment'], '')
AnnotateModels.do_annotations(options)
end
......
......@@ -8,6 +8,8 @@ task :annotate_routes => :environment do
options[:position_in_routes] = Annotate.fallback(ENV['position_in_routes'], ENV['position'])
options[:ignore_routes] = Annotate.fallback(ENV['ignore_routes'], nil)
options[:require] = ENV['require'] ? ENV['require'].split(',') : []
options[:wrapper_open] = Annotate.fallback(ENV['wrapper_open'], ENV['wrapper'])
options[:wrapper_close] = Annotate.fallback(ENV['wrapper_close'], ENV['wrapper'])
AnnotateRoutes.do_annotations(options)
end
......
......@@ -3,13 +3,17 @@ require File.dirname(__FILE__) + '/../spec_helper.rb'
require 'annotate/annotate_models'
require 'annotate/active_record_patch'
require 'active_support/core_ext/string'
require 'files'
describe AnnotateModels do
def mock_index(name, columns = [], unique = false)
def mock_index(name, params = {})
double('IndexKeyDefinition',
name: name,
columns: columns,
unique: unique)
columns: params[:columns] || [],
unique: params[:unique] || false,
orders: params[:orders] || {},
where: params[:where],
using: params[:using])
end
def mock_foreign_key(name, from_column, to_table, to_column = 'id', constraints = {})
......@@ -68,6 +72,31 @@ describe AnnotateModels do
it { expect(AnnotateModels.quote(BigDecimal.new('1.2'))).to eql('1.2') }
it { expect(AnnotateModels.quote([BigDecimal.new('1.2')])).to eql(['1.2']) }
describe '#parse_options' do
let(:options) do
{
root_dir: '/root',
model_dir: 'app/models,app/one, app/two ,,app/three'
}
end
it 'sets @root_dir' do
AnnotateModels.send(:parse_options, options)
expect(AnnotateModels.instance_variable_get(:@root_dir)).to eq('/root')
end
it 'sets @model_dir separated with a comma' do
AnnotateModels.send(:parse_options, options)
expected = [
'app/models',
'app/one',
'app/two',
'app/three'
]
expect(AnnotateModels.instance_variable_get(:@model_dir)).to eq(expected)
end
end
it 'should get schema info with default options' do
klass = mock_class(:users,
:id,
......@@ -154,7 +183,7 @@ EOS
[
mock_column(:id, :integer),
mock_column(:integer, :integer, unsigned?: true),
mock_column(:bigint, :bigint, unsigned?: true),
mock_column(:bigint, :integer, unsigned?: true, bigint?: true),
mock_column(:float, :float, unsigned?: true),
mock_column(:decimal, :decimal, unsigned?: true, precision: 10, scale: 2),
])
......@@ -302,8 +331,8 @@ EOS
[
mock_column(:id, :integer),
mock_column(:foreign_thing_id, :integer)
], [mock_index('index_rails_02e851e3b7', ['id']),
mock_index('index_rails_02e851e3b8', ['foreign_thing_id'])])
], [mock_index('index_rails_02e851e3b7', columns: ['id']),
mock_index('index_rails_02e851e3b8', columns: ['foreign_thing_id'])])
expect(AnnotateModels.get_schema_info(klass, 'Schema Info', show_indexes: true)).to eql(<<-EOS)
# Schema Info
#
......@@ -320,14 +349,118 @@ EOS
EOS
end
it 'should get ordered indexes keys' do
klass = mock_class(:users,
:id,
[
mock_column("id", :integer),
mock_column("firstname", :string),
mock_column("surname", :string),
mock_column("value", :string)
],
[
mock_index('index_rails_02e851e3b7', columns: ['id']),
mock_index('index_rails_02e851e3b8',
columns: %w(firstname surname value),
orders: { 'surname' => :asc, 'value' => :desc })
])
expect(AnnotateModels.get_schema_info(klass, 'Schema Info', show_indexes: true)).to eql(<<-EOS)
# Schema Info
#
# Table name: users
#
# id :integer not null, primary key
# firstname :string not null
# surname :string not null
# value :string not null
#
# Indexes
#
# index_rails_02e851e3b7 (id)
# index_rails_02e851e3b8 (firstname,surname ASC,value DESC)
#
EOS
end
it 'should get indexes keys with where clause' do
klass = mock_class(:users,
:id,
[
mock_column("id", :integer),
mock_column("firstname", :string),
mock_column("surname", :string),
mock_column("value", :string)
],
[
mock_index('index_rails_02e851e3b7', columns: ['id']),
mock_index('index_rails_02e851e3b8',
columns: %w(firstname surname),
where: 'value IS NOT NULL')
])
expect(AnnotateModels.get_schema_info(klass, 'Schema Info', show_indexes: true)).to eql(<<-EOS)
# Schema Info
#
# Table name: users
#
# id :integer not null, primary key
# firstname :string not null
# surname :string not null
# value :string not null
#
# Indexes
#
# index_rails_02e851e3b7 (id)
# index_rails_02e851e3b8 (firstname,surname) WHERE value IS NOT NULL
#
EOS
end
it 'should get indexes keys with using clause other than btree' do
klass = mock_class(:users,
:id,
[
mock_column("id", :integer),
mock_column("firstname", :string),
mock_column("surname", :string),
mock_column("value", :string)
],
[
mock_index('index_rails_02e851e3b7', columns: ['id']),
mock_index('index_rails_02e851e3b8',
columns: %w(firstname surname),
using: 'hash')
])
expect(AnnotateModels.get_schema_info(klass, 'Schema Info', show_indexes: true)).to eql(<<-EOS)
# Schema Info
#
# Table name: users
#
# id :integer not null, primary key
# firstname :string not null
# surname :string not null
# value :string not null
#
# Indexes
#
# index_rails_02e851e3b7 (id)
# index_rails_02e851e3b8 (firstname,surname) USING hash
#
EOS
end
it 'should get simple indexes keys' do
klass = mock_class(:users,
:id,
[
mock_column(:id, :integer),
mock_column(:foreign_thing_id, :integer)
], [mock_index('index_rails_02e851e3b7', ['id']),
mock_index('index_rails_02e851e3b8', ['foreign_thing_id'])])
],
[
mock_index('index_rails_02e851e3b7', columns: ['id']),
mock_index('index_rails_02e851e3b8',
columns: ['foreign_thing_id'],
orders: { 'foreign_thing_id' => :desc })
])
expect(AnnotateModels.get_schema_info(klass, 'Schema Info', simple_indexes: true)).to eql(<<-EOS)
# Schema Info
#
......@@ -345,8 +478,8 @@ EOS
[
mock_column("id", :integer),
mock_column("name", :string)
], [mock_index('index_rails_02e851e3b7', ['id']),
mock_index('index_rails_02e851e3b8', 'LOWER(name)')])
], [mock_index('index_rails_02e851e3b7', columns: ['id']),
mock_index('index_rails_02e851e3b8', columns: 'LOWER(name)')])
expect(AnnotateModels.get_schema_info(klass, 'Schema Info', simple_indexes: true)).to eql(<<-EOS)
# Schema Info
#
......@@ -460,8 +593,12 @@ EOS
[
mock_column(:id, :integer),
mock_column(:name, :string, limit: 50)
], [mock_index('index_rails_02e851e3b7', ['id']),
mock_index('index_rails_02e851e3b8', ['foreign_thing_id'])])
],
[
mock_index('index_rails_02e851e3b7', columns: ['id']),
mock_index('index_rails_02e851e3b8',
columns: ['foreign_thing_id'])
])
expect(AnnotateModels.get_schema_info(klass, AnnotateModels::PREFIX, format_markdown: true, show_indexes: true)).to eql(<<-EOS)
# #{AnnotateModels::PREFIX}
#
......@@ -484,6 +621,162 @@ EOS
EOS
end
it 'should get schema info as Markdown with unique indexes' do
klass = mock_class(:users,
:id,
[
mock_column(:id, :integer),
mock_column(:name, :string, limit: 50)
],
[
mock_index('index_rails_02e851e3b7', columns: ['id']),
mock_index('index_rails_02e851e3b8',
columns: ['foreign_thing_id'],
unique: true)
])
expect(AnnotateModels.get_schema_info(klass, AnnotateModels::PREFIX, format_markdown: true, show_indexes: true)).to eql(<<-EOS)
# #{AnnotateModels::PREFIX}
#
# Table name: `users`
#
# ### Columns
#
# Name | Type | Attributes
# ----------- | ------------------ | ---------------------------
# **`id`** | `integer` | `not null, primary key`
# **`name`** | `string(50)` | `not null`
#
# ### Indexes
#
# * `index_rails_02e851e3b7`:
# * **`id`**
# * `index_rails_02e851e3b8` (_unique_):
# * **`foreign_thing_id`**
#
EOS
end
it 'should get schema info as Markdown with ordered indexes' do
klass = mock_class(:users,
:id,
[
mock_column(:id, :integer),
mock_column(:name, :string, limit: 50)
],
[
mock_index('index_rails_02e851e3b7', columns: ['id']),
mock_index('index_rails_02e851e3b8',
columns: ['foreign_thing_id'],
orders: { 'foreign_thing_id' => :desc })
])
expect(AnnotateModels.get_schema_info(klass, AnnotateModels::PREFIX, format_markdown: true, show_indexes: true)).to eql(<<-EOS)
# #{AnnotateModels::PREFIX}
#
# Table name: `users`
#
# ### Columns
#
# Name | Type | Attributes
# ----------- | ------------------ | ---------------------------
# **`id`** | `integer` | `not null, primary key`
# **`name`** | `string(50)` | `not null`
#
# ### Indexes
#
# * `index_rails_02e851e3b7`:
# * **`id`**
# * `index_rails_02e851e3b8`:
# * **`foreign_thing_id DESC`**
#
EOS
end
it 'should get schema info as Markdown with indexes with WHERE clause' do
klass = mock_class(:users,
:id,
[
mock_column(:id, :integer),
mock_column(:name, :string, limit: 50)
],
[
mock_index('index_rails_02e851e3b7', columns: ['id']),
mock_index('index_rails_02e851e3b8',
columns: ['foreign_thing_id'],
unique: true,
where: 'name IS NOT NULL')
])
expect(AnnotateModels.get_schema_info(klass, AnnotateModels::PREFIX, format_markdown: true, show_indexes: true)).to eql(<<-EOS)
# #{AnnotateModels::PREFIX}
#
# Table name: `users`
#
# ### Columns
#
# Name | Type | Attributes
# ----------- | ------------------ | ---------------------------
# **`id`** | `integer` | `not null, primary key`
# **`name`** | `string(50)` | `not null`
#
# ### Indexes
#
# * `index_rails_02e851e3b7`:
# * **`id`**
# * `index_rails_02e851e3b8` (_unique_ _where_ name IS NOT NULL):
# * **`foreign_thing_id`**
#
EOS
end
it 'should get schema info as Markdown with indexes with using clause other than btree' do
klass = mock_class(:users,
:id,
[
mock_column(:id, :integer),
mock_column(:name, :string, limit: 50)
],
[
mock_index('index_rails_02e851e3b7', columns: ['id']),
mock_index('index_rails_02e851e3b8',
columns: ['foreign_thing_id'],
using: 'hash')
])
expect(AnnotateModels.get_schema_info(klass, AnnotateModels::PREFIX, format_markdown: true, show_indexes: true)).to eql(<<-EOS)
# #{AnnotateModels::PREFIX}
#
# Table name: `users`
#
# ### Columns
#
# Name | Type | Attributes
# ----------- | ------------------ | ---------------------------
# **`id`** | `integer` | `not null, primary key`
# **`name`** | `string(50)` | `not null`
#
# ### Indexes
#
# * `index_rails_02e851e3b7`:
# * **`id`**
# * `index_rails_02e851e3b8` (_using_ hash):
# * **`foreign_thing_id`**
#
EOS
end
describe '#set_defaults' do
it 'should default show_complete_foreign_keys to false' do
expect(Annotate.true?(ENV['show_complete_foreign_keys'])).to be(false)
end
it 'should be able to set show_complete_foreign_keys to true' do
Annotate.set_defaults('show_complete_foreign_keys' => 'true')
expect(Annotate.true?(ENV['show_complete_foreign_keys'])).to be(true)
end
after :each do
ENV.delete('show_complete_foreign_keys')
end
end
describe '#get_schema_info with custom options' do
def self.when_called_with(options = {})
expected = options.delete(:returns)
......@@ -620,10 +913,11 @@ EOS
describe 'with_comment option' do
mocked_columns_with_comment = [
[:id, :integer, { limit: 8, comment: 'ID' }],
[:active, :boolean, { limit: 1, comment: 'Active' }],
[:name, :string, { limit: 50, comment: 'Name' }],
[:notes, :text, { limit: 55, comment: 'Notes' }]
[:id, :integer, { limit: 8, comment: 'ID' }],
[:active, :boolean, { limit: 1, comment: 'Active' }],
[:name, :string, { limit: 50, comment: 'Name' }],
[:notes, :text, { limit: 55, comment: 'Notes' }],
[:no_comment, :text, { limit: 20, comment: nil }]
]
when_called_with with_comment: 'yes',
......@@ -637,6 +931,7 @@ EOS
# active(Active) :boolean not null
# name(Name) :string(50) not null
# notes(Notes) :text(55) not null
# no_comment :text(20) not null
#
EOS
......@@ -684,6 +979,121 @@ EOS
end
end
describe '#get_model_files' do
subject { described_class.get_model_files(options) }
before do
ARGV.clear
described_class.model_dir = [model_dir]
end
context 'when `model_dir` is valid' do
let(:model_dir) do
Files do
file 'foo.rb'
dir 'bar' do
file 'baz.rb'
dir 'qux' do
file 'quux.rb'
end
end
dir 'concerns' do
file 'corge.rb'
end
end
end
context 'when the model files are not specified' do
context 'when no option is specified' do
let(:options) { {} }
it 'returns all model files under `model_dir` directory' do
is_expected.to contain_exactly(
[model_dir, 'foo.rb'],
[model_dir, File.join('bar', 'baz.rb')],
[model_dir, File.join('bar', 'qux', 'quux.rb')]
)
end
end
context 'when `ignore_model_sub_dir` option is enabled' do
let(:options) { { ignore_model_sub_dir: true } }
it 'returns model files just below `model_dir` directory' do
is_expected.to contain_exactly([model_dir, 'foo.rb'])
end
end
end
context 'when the model files are specified' do
let(:additional_model_dir) { 'additional_model' }
let(:model_files) do
[
File.join(model_dir, 'foo.rb'),
"./#{File.join(additional_model_dir, 'corge/grault.rb')}" # Specification by relative path
]
end
before { ARGV.concat(model_files) }
context 'when no option is specified' do
let(:options) { {} }
context 'when all the specified files are in `model_dir` directory' do
before do
described_class.model_dir << additional_model_dir
end
it 'returns specified files' do
is_expected.to contain_exactly(
[model_dir, 'foo.rb'],
[additional_model_dir, 'corge/grault.rb']
)
end
end
context 'when a model file outside `model_dir` directory is specified' do
it 'exits with the status code' do
begin
subject
raise
rescue SystemExit => e
expect(e.status).to eq(1)
end
end
end
end
context 'when `is_rake` option is enabled' do
let(:options) { { is_rake: true } }
it 'returns all model files under `model_dir` directory' do
is_expected.to contain_exactly(
[model_dir, 'foo.rb'],
[model_dir, File.join('bar', 'baz.rb')],
[model_dir, File.join('bar', 'qux', 'quux.rb')]
)
end
end
end
end
context 'when `model_dir` is invalid' do
let(:model_dir) { '/not_exist_path' }
let(:options) { {} }
it 'exits with the status code' do
begin
subject
raise
rescue SystemExit => e
expect(e.status).to eq(1)
end
end
end
end
describe '#get_model_class' do
require 'tmpdir'
......@@ -847,11 +1257,29 @@ EOS
EOS
path = File.expand_path('loaded_class', AnnotateModels.model_dir[0])
Kernel.load "#{path}.rb"
expect(Kernel).not_to receive(:require).with(path)
expect(Kernel).not_to receive(:require)
expect(capturing(:stderr) do
check_class_name 'loaded_class.rb', 'LoadedClass'
end).not_to include('warning: already initialized constant LoadedClass::CONSTANT')
end).to be_blank
end
it 'should not require model files twice which is inside a subdirectory' do
dir = Array.new(8) { (0..9).to_a.sample(random: Random.new) }.join
$LOAD_PATH.unshift(File.join(AnnotateModels.model_dir[0], dir))
create "#{dir}/subdir_loaded_class.rb", <<-EOS
class SubdirLoadedClass < ActiveRecord::Base
CONSTANT = 1
end
EOS
path = File.expand_path("#{dir}/subdir_loaded_class", AnnotateModels.model_dir[0])
Kernel.load "#{path}.rb"
expect(Kernel).not_to receive(:require)
expect(capturing(:stderr) do
check_class_name "#{dir}/subdir_loaded_class.rb", 'SubdirLoadedClass'
end).to be_blank
end
end
......@@ -898,6 +1326,28 @@ end
EOS
end
it 'should remove annotate if CRLF is used for line breaks' do
path = create 'before.rb', <<-EOS
# == Schema Information
#
# Table name: foo\r\n#
# id :integer not null, primary key
# created_at :datetime
# updated_at :datetime
#
\r\n
class Foo < ActiveRecord::Base
end
EOS
AnnotateModels.remove_annotation_of_file(path)
expect(content(path)).to eq <<-EOS
class Foo < ActiveRecord::Base
end
EOS
end
it 'should remove after annotate' do
path = create 'after.rb', <<-EOS
class Foo < ActiveRecord::Base
......@@ -946,6 +1396,29 @@ end
EOS
end
it 'should remove wrapper if CRLF is used for line breaks' do
path = create 'opening_wrapper.rb', <<-EOS
# wrapper\r\n# == Schema Information
#
# Table name: foo
#
# id :integer not null, primary key
# created_at :datetime
# updated_at :datetime
#
class Foo < ActiveRecord::Base
end
EOS
AnnotateModels.remove_annotation_of_file(path, wrapper_open: 'wrapper')
expect(content(path)).to eq <<-EOS
class Foo < ActiveRecord::Base
end
EOS
end
it 'should remove closing wrapper' do
path = create 'closing_wrapper.rb', <<-EOS
class Foo < ActiveRecord::Base
......@@ -970,6 +1443,28 @@ class Foo < ActiveRecord::Base
end
EOS
end
it 'does not change file with #SkipSchemaAnnotations' do
content = <<-EOS
# -*- SkipSchemaAnnotations
# == Schema Information
#
# Table name: foo
#
# id :integer not null, primary key
# created_at :datetime
# updated_at :datetime
#
class Foo < ActiveRecord::Base
end
EOS
path = create 'skip.rb', content
AnnotateModels.remove_annotation_of_file(path)
expect(content(path)).to eq(content)
end
end
describe '#resolve_filename' do
......@@ -1164,9 +1659,33 @@ end
end
end
it 'adds an empty line between magic comments and annotation (position :before)' do
content = "class User < ActiveRecord::Base\nend\n"
magic_comments_list_each do |magic_comment|
model_file_name, = write_model 'user.rb', "#{magic_comment}\n#{content}"
annotate_one_file position: :before
schema_info = AnnotateModels.get_schema_info(@klass, '== Schema Info')
expect(File.read(model_file_name)).to eq("#{magic_comment}\n\n#{schema_info}\n#{content}")
end
end
it 'adds an empty line between magic comments and model file content (position :after)' do
content = "class User < ActiveRecord::Base\nend\n"
magic_comments_list_each do |magic_comment|
model_file_name, = write_model 'user.rb', "#{magic_comment}\n#{content}"
annotate_one_file position: :after
schema_info = AnnotateModels.get_schema_info(@klass, '== Schema Info')
expect(File.read(model_file_name)).to eq("#{magic_comment}\n\n#{content}\n#{schema_info}")
end
end
describe "if a file can't be annotated" do
before do
allow(AnnotateModels).to receive(:get_loaded_model).with('user').and_return(nil)
allow(AnnotateModels).to receive(:get_loaded_model_by_path).with('user').and_return(nil)
write_model('user.rb', <<-EOS)
class User < ActiveRecord::Base
......@@ -1175,28 +1694,28 @@ end
EOS
end
it 'displays an error message' do
expect(capturing(:stdout) do
it 'displays just the error message with trace disabled (default)' do
error_output = capturing(:stderr) do
AnnotateModels.do_annotations model_dir: @model_dir, is_rake: true
end).to include("Unable to annotate #{@model_dir}/user.rb: oops")
end
end
it 'displays the full stack trace with --trace' do
expect(capturing(:stdout) do
AnnotateModels.do_annotations model_dir: @model_dir, trace: true, is_rake: true
end).to include('/spec/annotate/annotate_models_spec.rb:')
expect(error_output).to include("Unable to annotate #{@model_dir}/user.rb: oops")
expect(error_output).not_to include('/spec/annotate/annotate_models_spec.rb:')
end
it 'omits the full stack trace without --trace' do
expect(capturing(:stdout) do
AnnotateModels.do_annotations model_dir: @model_dir, trace: false, is_rake: true
end).not_to include('/spec/annotate/annotate_models_spec.rb:')
it 'displays the error message and stacktrace with trace enabled' do
error_output = capturing(:stderr) do
AnnotateModels.do_annotations model_dir: @model_dir, is_rake: true, trace: true
end
expect(error_output).to include("Unable to annotate #{@model_dir}/user.rb: oops")
expect(error_output).to include('/spec/annotate/annotate_models_spec.rb:')
end
end
describe "if a file can't be deannotated" do
before do
allow(AnnotateModels).to receive(:get_loaded_model).with('user').and_return(nil)
allow(AnnotateModels).to receive(:get_loaded_model_by_path).with('user').and_return(nil)
write_model('user.rb', <<-EOS)
class User < ActiveRecord::Base
......@@ -1205,22 +1724,22 @@ end
EOS
end
it 'displays an error message' do
expect(capturing(:stdout) do
it 'displays just the error message with trace disabled (default)' do
error_output = capturing(:stderr) do
AnnotateModels.remove_annotations model_dir: @model_dir, is_rake: true
end).to include("Unable to deannotate #{@model_dir}/user.rb: oops")
end
end
it 'displays the full stack trace' do
expect(capturing(:stdout) do
AnnotateModels.remove_annotations model_dir: @model_dir, trace: true, is_rake: true
end).to include("/user.rb:2:in `<class:User>'")
expect(error_output).to include("Unable to deannotate #{@model_dir}/user.rb: oops")
expect(error_output).not_to include("/user.rb:2:in `<class:User>'")
end
it 'omits the full stack trace without --trace' do
expect(capturing(:stdout) do
AnnotateModels.remove_annotations model_dir: @model_dir, trace: false, is_rake: true
end).not_to include("/user.rb:2:in `<class:User>'")
it 'displays the error message and stacktrace with trace enabled' do
error_output = capturing(:stderr) do
AnnotateModels.remove_annotations model_dir: @model_dir, is_rake: true, trace: true
end
expect(error_output).to include("Unable to deannotate #{@model_dir}/user.rb: oops")
expect(error_output).to include("/user.rb:2:in `<class:User>'")
end
end
end
......
......@@ -11,6 +11,22 @@ describe AnnotateRoutes do
@mock_file ||= double(File, stubs)
end
def magic_comments_list_each
[
'# encoding: UTF-8',
'# coding: UTF-8',
'# -*- coding: UTF-8 -*-',
'#encoding: utf-8',
'# encoding: utf-8',
'# -*- encoding : utf-8 -*-',
"# encoding: utf-8\n# frozen_string_literal: true",
"# frozen_string_literal: true\n# encoding: utf-8",
'# frozen_string_literal: true',
'#frozen_string_literal: false',
'# -*- frozen_string_literal : true -*-'
].each { |magic_comment| yield magic_comment }
end
it 'should check if routes.rb exists' do
expect(File).to receive(:exists?).with(ROUTE_FILE).and_return(false)
expect(AnnotateRoutes).to receive(:puts).with("Can't find routes.rb")
......@@ -18,21 +34,29 @@ describe AnnotateRoutes do
end
describe 'Annotate#example' do
before(:each) do
expect(File).to receive(:exists?).with(ROUTE_FILE).and_return(true)
expect(File).to receive(:read).with(ROUTE_FILE).and_return("")
expect(AnnotateRoutes).to receive(:`).with('rake routes').and_return(' Prefix Verb URI Pattern Controller#Action
let(:rake_routes_content) do
" Prefix Verb URI Pattern Controller#Action
myaction1 GET /url1(.:format) mycontroller1#action
myaction2 POST /url2(.:format) mycontroller2#action
myaction3 DELETE|GET /url3(.:format) mycontroller3#action')
myaction3 DELETE|GET /url3(.:format) mycontroller3#action\n"
end
expect(AnnotateRoutes).to receive(:puts).with(ANNOTATION_ADDED)
before(:each) do
expect(File).to receive(:exists?).with(ROUTE_FILE).and_return(true).at_least(:once)
expect(File).to receive(:read).with(ROUTE_FILE).and_return("").at_least(:once)
expect(AnnotateRoutes).to receive(:puts).with(ANNOTATION_ADDED).at_least(:once)
end
it 'annotate normal' do
expect(File).to receive(:open).with(ROUTE_FILE, 'wb').and_yield(mock_file)
expect(@mock_file).to receive(:puts).with("
context 'without magic comments' do
before(:each) do
expect(AnnotateRoutes).to receive(:`).with('rake routes').and_return(rake_routes_content)
end
it 'annotate normal' do
expect(File).to receive(:open).with(ROUTE_FILE, 'wb').and_yield(mock_file)
expect(@mock_file).to receive(:puts).with("
# == Route Map
#
# Prefix Verb URI Pattern Controller#Action
......@@ -40,12 +64,69 @@ describe AnnotateRoutes do
# myaction2 POST /url2(.:format) mycontroller2#action
# myaction3 DELETE|GET /url3(.:format) mycontroller3#action\n")
AnnotateRoutes.do_annotations
AnnotateRoutes.do_annotations
end
it 'annotate markdown' do
expect(File).to receive(:open).with(ROUTE_FILE, 'wb').and_yield(mock_file)
expect(@mock_file).to receive(:puts).with("
# ## Route Map
#
# Prefix | Verb | URI Pattern | Controller#Action
# --------- | ---------- | --------------- | --------------------
# myaction1 | GET | /url1(.:format) | mycontroller1#action
# myaction2 | POST | /url2(.:format) | mycontroller2#action
# myaction3 | DELETE-GET | /url3(.:format) | mycontroller3#action\n")
AnnotateRoutes.do_annotations(format_markdown: true)
end
it 'wraps annotation if wrapper is specified' do
expect(File).to receive(:open).with(ROUTE_FILE, 'wb').and_yield(mock_file)
expect(@mock_file).to receive(:puts).with("
# START
# == Route Map
#
# Prefix Verb URI Pattern Controller#Action
# myaction1 GET /url1(.:format) mycontroller1#action
# myaction2 POST /url2(.:format) mycontroller2#action
# myaction3 DELETE|GET /url3(.:format) mycontroller3#action
# END\n")
AnnotateRoutes.do_annotations(wrapper_open: 'START', wrapper_close: 'END')
end
end
it 'annotate markdown' do
expect(File).to receive(:open).with(ROUTE_FILE, 'wb').and_yield(mock_file)
expect(@mock_file).to receive(:puts).with("
context 'file with magic comments' do
it 'should not remove magic comments' do
magic_comments_list_each do |magic_comment|
expect(AnnotateRoutes).to receive(:`).with('rake routes')
.and_return("#{magic_comment}\n#{rake_routes_content}")
expect(File).to receive(:open).with(ROUTE_FILE, 'wb').and_yield(mock_file)
expect(@mock_file).to receive(:puts).with("
#{magic_comment}
# == Route Map
#
# Prefix Verb URI Pattern Controller#Action
# myaction1 GET /url1(.:format) mycontroller1#action
# myaction2 POST /url2(.:format) mycontroller2#action
# myaction3 DELETE|GET /url3(.:format) mycontroller3#action\n")
AnnotateRoutes.do_annotations
end
end
it 'annotate markdown' do
magic_comments_list_each do |magic_comment|
expect(AnnotateRoutes).to receive(:`).with('rake routes')
.and_return("#{magic_comment}\n#{rake_routes_content}")
expect(File).to receive(:open).with(ROUTE_FILE, 'wb').and_yield(mock_file)
expect(@mock_file).to receive(:puts).with("
#{magic_comment}
# ## Route Map
#
# Prefix | Verb | URI Pattern | Controller#Action
......@@ -54,14 +135,39 @@ describe AnnotateRoutes do
# myaction2 | POST | /url2(.:format) | mycontroller2#action
# myaction3 | DELETE-GET | /url3(.:format) | mycontroller3#action\n")
AnnotateRoutes.do_annotations(format_markdown: true)
AnnotateRoutes.do_annotations(format_markdown: true)
end
end
it 'wraps annotation if wrapper is specified' do
magic_comments_list_each do |magic_comment|
expect(AnnotateRoutes).to receive(:`).with('rake routes')
.and_return("#{magic_comment}\n#{rake_routes_content}")
expect(File).to receive(:open).with(ROUTE_FILE, 'wb').and_yield(mock_file)
expect(@mock_file).to receive(:puts).with("
#{magic_comment}
# START
# == Route Map
#
# Prefix Verb URI Pattern Controller#Action
# myaction1 GET /url1(.:format) mycontroller1#action
# myaction2 POST /url2(.:format) mycontroller2#action
# myaction3 DELETE|GET /url3(.:format) mycontroller3#action
# END\n")
AnnotateRoutes.do_annotations(wrapper_open: 'START', wrapper_close: 'END')
end
end
end
end
describe 'When adding' do
before(:each) do
expect(File).to receive(:exists?).with(ROUTE_FILE).and_return(true)
expect(AnnotateRoutes).to receive(:`).with('rake routes').and_return('')
expect(File).to receive(:exists?).with(ROUTE_FILE)
.and_return(true).at_least(:once)
expect(AnnotateRoutes).to receive(:`).with('rake routes')
.and_return('').at_least(:once)
end
it 'should insert annotations if file does not contain annotations' do
......@@ -97,6 +203,42 @@ describe AnnotateRoutes do
AnnotateRoutes.do_annotations
end
context 'file with magic comments' do
it 'leaves magic comment on top, adds an empty line between magic comment and annotation (position_in_routes :top)' do
expect(File).to receive(:open).with(ROUTE_FILE, 'wb')
.and_yield(mock_file).at_least(:once)
magic_comments_list_each do |magic_comment|
expect(File).to receive(:read).with(ROUTE_FILE).and_return("#{magic_comment}\nSomething")
expect(@mock_file).to receive(:puts).with("#{magic_comment}\n\n# == Route Map\n#\n\nSomething\n")
expect(AnnotateRoutes).to receive(:puts).with(ANNOTATION_ADDED)
AnnotateRoutes.do_annotations(position_in_routes: 'top')
end
end
it 'leaves magic comment on top, adds an empty line between magic comment and annotation (position_in_routes :bottom)' do
expect(File).to receive(:open).with(ROUTE_FILE, 'wb')
.and_yield(mock_file).at_least(:once)
magic_comments_list_each do |magic_comment|
expect(File).to receive(:read).with(ROUTE_FILE).and_return("#{magic_comment}\nSomething")
expect(@mock_file).to receive(:puts).with("#{magic_comment}\nSomething\n\n# == Route Map\n#\n")
expect(AnnotateRoutes).to receive(:puts).with(ANNOTATION_ADDED)
AnnotateRoutes.do_annotations(position_in_routes: 'bottom')
end
end
it 'skips annotations if file does already contain annotation' do
magic_comments_list_each do |magic_comment|
expect(File).to receive(:read).with(ROUTE_FILE)
.and_return("#{magic_comment}\n\n# == Route Map\n#\n")
expect(AnnotateRoutes).to receive(:puts).with(FILE_UNCHANGED)
AnnotateRoutes.do_annotations
end
end
end
end
describe 'When adding with older Rake versions' do
......@@ -155,14 +297,82 @@ describe AnnotateRoutes do
end
it 'should remove trailing annotation and trim trailing newlines, but leave leading newlines alone' do
expect(File).to receive(:read).with(ROUTE_FILE).and_return("\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nActionController::Routing...\nfoo\n\n\n\n\n\n\n\n\n\n\n# == Route Map\n#\n# another good line\n# good line\n")
expect(@mock_file).to receive(:puts).with(/\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nActionController::Routing...\nfoo\n/)
expect(File).to receive(:read).with(ROUTE_FILE).and_return(<<-EOS
ActionController::Routing...
foo
# == Route Map
#
# another good line
# good line
EOS
)
expect(@mock_file).to receive(:puts).with(<<-EOS
ActionController::Routing...
foo
EOS
)
AnnotateRoutes.remove_annotations
end
it 'should remove prepended annotation and trim leading newlines, but leave trailing newlines alone' do
expect(File).to receive(:read).with(ROUTE_FILE).and_return("# == Route Map\n#\n# another good line\n# good line\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nActionController::Routing...\nfoo\n\n\n\n\n\n\n\n\n\n\n")
expect(@mock_file).to receive(:puts).with(/ActionController::Routing...\nfoo\n\n\n\n\n\n\n\n\n\n\n/)
expect(File).to receive(:read).with(ROUTE_FILE).and_return(<<-EOS
# == Route Map
#
# another good line
# good line
Rails.application.routes.draw do
root 'root#index'
end
EOS
)
expect(@mock_file).to receive(:puts).with(<<-EOS
Rails.application.routes.draw do
root 'root#index'
end
EOS
)
AnnotateRoutes.remove_annotations
end
it 'should not remove custom comments above route map' do
expect(File).to receive(:read).with(ROUTE_FILE).and_return(<<-EOS
# My comment
# == Route Map
#
# another good line
# good line
Rails.application.routes.draw do
root 'root#index'
end
EOS
)
expect(@mock_file).to receive(:puts).with(<<-EOS
# My comment
Rails.application.routes.draw do
root 'root#index'
end
EOS
)
AnnotateRoutes.remove_annotations
end
end
......
......@@ -63,7 +63,7 @@ module Rails
end
rescue Gem::LoadError => load_error
if load_error.message =~ /Could not find RubyGem rails/
STDERR.puts %(Missing the Rails #{version} gem. Please `gem install -v=#{version} rails`, update your RAILS_GEM_VERSION setting in config/environment.rb for the Rails version you do have installed, or comment out RAILS_GEM_VERSION to use the latest version installed.)
$stderr.puts "Missing the Rails #{version} gem. Please `gem install -v=#{version} rails`, update your RAILS_GEM_VERSION setting in config/environment.rb for the Rails version you do have installed, or comment out RAILS_GEM_VERSION to use the latest version installed."
exit 1
else
raise
......
class NoNamespace < ActiveRecord::Base
enum foo: [:bar, :baz]
end
class CreateUsers < ActiveRecord::Migration
def change
create_table :no_namespaces do |t|
t.integer :foo
t.timestamps
end
end
end
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment