Skip to content

Commit 4fac6fa

Browse files
authored
Refactor model annotator (#19)
* Huge refactor of `SchemaInfo` for model annotator into separate classes * Fixes cases of `can't modify frozen String: "bigint"` * Fixes cases of `no implicit conversion of nil into Array`
1 parent 178f022 commit 4fac6fa

18 files changed

+1092
-565
lines changed

lib/annotate_rb/model_annotator.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ module ModelAnnotator
66
autoload :Helper, 'annotate_rb/model_annotator/helper'
77
autoload :FilePatterns, 'annotate_rb/model_annotator/file_patterns'
88
autoload :Constants, 'annotate_rb/model_annotator/constants'
9-
autoload :SchemaInfo, 'annotate_rb/model_annotator/schema_info'
109
autoload :PatternGetter, 'annotate_rb/model_annotator/pattern_getter'
1110
autoload :BadModelFileError, 'annotate_rb/model_annotator/bad_model_file_error'
1211
autoload :FileNameResolver, 'annotate_rb/model_annotator/file_name_resolver'
@@ -16,5 +15,13 @@ module ModelAnnotator
1615
autoload :ModelFilesGetter, 'annotate_rb/model_annotator/model_files_getter'
1716
autoload :FileAnnotator, 'annotate_rb/model_annotator/file_annotator'
1817
autoload :ModelFileAnnotator, 'annotate_rb/model_annotator/model_file_annotator'
18+
autoload :ModelWrapper, 'annotate_rb/model_annotator/model_wrapper'
19+
autoload :AnnotationGenerator, 'annotate_rb/model_annotator/annotation_generator'
20+
autoload :ColumnAttributesBuilder, 'annotate_rb/model_annotator/column_attributes_builder'
21+
autoload :ColumnTypeBuilder, 'annotate_rb/model_annotator/column_type_builder'
22+
autoload :ColumnWrapper, 'annotate_rb/model_annotator/column_wrapper'
23+
autoload :ColumnAnnotationBuilder, 'annotate_rb/model_annotator/column_annotation_builder'
24+
autoload :IndexAnnotationBuilder, 'annotate_rb/model_annotator/index_annotation_builder'
25+
autoload :ForeignKeyAnnotationBuilder, 'annotate_rb/model_annotator/foreign_key_annotation_builder'
1926
end
2027
end
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# frozen_string_literal: true
2+
3+
module AnnotateRb
4+
module ModelAnnotator
5+
class AnnotationGenerator
6+
# Annotate Models plugin use this header
7+
PREFIX = '== Schema Information'.freeze
8+
PREFIX_MD = '## Schema Information'.freeze
9+
10+
END_MARK = '== Schema Information End'.freeze
11+
12+
MD_NAMES_OVERHEAD = 6
13+
MD_TYPE_ALLOWANCE = 18
14+
15+
def initialize(klass, header, options = {})
16+
@header = header
17+
@options = options
18+
@model = ModelWrapper.new(klass, options)
19+
@info = "" # TODO: Make array and build string that way
20+
end
21+
22+
def generate
23+
@info = "# #{header}\n"
24+
@info += schema_header_text
25+
26+
max_size = @model.max_schema_info_width
27+
28+
if @options[:format_markdown]
29+
@info += format("# %-#{max_size + MD_NAMES_OVERHEAD}.#{max_size + MD_NAMES_OVERHEAD}s | %-#{MD_TYPE_ALLOWANCE}.#{MD_TYPE_ALLOWANCE}s | %s\n",
30+
'Name',
31+
'Type',
32+
'Attributes')
33+
@info += "# #{'-' * (max_size + MD_NAMES_OVERHEAD)} | #{'-' * MD_TYPE_ALLOWANCE} | #{'-' * 27}\n"
34+
end
35+
36+
@info += @model.columns.map do |col|
37+
ColumnAnnotationBuilder.new(col, @model, max_size, @options).build
38+
end.join
39+
40+
if @options[:show_indexes] && @model.table_exists?
41+
@info += IndexAnnotationBuilder.new(@model, @options).build
42+
end
43+
44+
if @options[:show_foreign_keys] && @model.table_exists?
45+
@info += ForeignKeyAnnotationBuilder.new(@model, @options).build
46+
end
47+
48+
@info += schema_footer_text
49+
50+
@info
51+
end
52+
53+
# TODO: Move header logic into here from AnnotateRb::ModelAnnotator::Annotator.do_annotations
54+
def header
55+
@header
56+
end
57+
58+
def schema_header_text
59+
info = []
60+
info << "#"
61+
62+
if @options[:format_markdown]
63+
info << "# Table name: `#{@model.table_name}`"
64+
info << "#"
65+
info << "# ### Columns"
66+
else
67+
info << "# Table name: #{@model.table_name}"
68+
end
69+
info << "#\n" # We want the last line break
70+
71+
info.join("\n")
72+
end
73+
74+
def schema_footer_text
75+
info = []
76+
77+
if @options[:format_rdoc]
78+
info << "#--"
79+
info << "# #{END_MARK}"
80+
info << "#++\n"
81+
else
82+
info << "#\n"
83+
end
84+
85+
info.join("\n")
86+
end
87+
end
88+
end
89+
end
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# frozen_string_literal: true
2+
3+
module AnnotateRb
4+
module ModelAnnotator
5+
class ColumnAnnotationBuilder
6+
BARE_TYPE_ALLOWANCE = 16
7+
MD_TYPE_ALLOWANCE = 18
8+
9+
def initialize(column, model, max_size, options)
10+
@column = column
11+
@model = model
12+
@max_size = max_size
13+
@options = options
14+
end
15+
16+
def build
17+
result = ''
18+
19+
is_primary_key = is_column_primary_key?(@model, @column.name)
20+
21+
table_indices = @model.retrieve_indexes_from_table
22+
column_indices = table_indices.select { |ind| ind.columns.include?(@column.name) }
23+
24+
column_attributes = ColumnAttributesBuilder.new(@column, @options, is_primary_key, column_indices).build
25+
formatted_column_type = ColumnTypeBuilder.new(@column, @options).build
26+
27+
col_name = if @model.with_comments? && @column.comment
28+
"#{@column.name}(#{@column.comment.gsub(/\n/, '\\n')})"
29+
else
30+
@column.name
31+
end
32+
33+
if @options[:format_rdoc]
34+
result += format("# %-#{@max_size}.#{@max_size}s<tt>%s</tt>",
35+
"*#{col_name}*::",
36+
column_attributes.unshift(formatted_column_type).join(', ')).rstrip + "\n"
37+
elsif @options[:format_yard]
38+
result += sprintf("# @!attribute #{col_name}") + "\n"
39+
40+
if @column.respond_to?(:array) && @column.array
41+
ruby_class = "Array<#{Helper.map_col_type_to_ruby_classes(formatted_column_type)}>"
42+
else
43+
ruby_class = Helper.map_col_type_to_ruby_classes(formatted_column_type)
44+
end
45+
46+
result += sprintf("# @return [#{ruby_class}]") + "\n"
47+
elsif @options[:format_markdown]
48+
name_remainder = @max_size - col_name.length - Helper.non_ascii_length(col_name)
49+
type_remainder = (MD_TYPE_ALLOWANCE - 2) - formatted_column_type.length
50+
result += format("# **`%s`**%#{name_remainder}s | `%s`%#{type_remainder}s | `%s`",
51+
col_name,
52+
' ',
53+
formatted_column_type,
54+
' ',
55+
column_attributes.join(', ').rstrip).gsub('``', ' ').rstrip + "\n"
56+
else
57+
result += format_default(col_name, @max_size, formatted_column_type, column_attributes)
58+
end
59+
60+
result
61+
end
62+
63+
private
64+
65+
def format_default(col_name, max_size, col_type, attrs)
66+
format('# %s:%s %s',
67+
Helper.mb_chars_ljust(col_name, max_size),
68+
Helper.mb_chars_ljust(col_type, BARE_TYPE_ALLOWANCE),
69+
attrs.join(', ')).rstrip + "\n"
70+
end
71+
72+
# TODO: Simplify this conditional
73+
def is_column_primary_key?(model, column_name)
74+
if model.primary_key
75+
if model.primary_key.is_a?(Array)
76+
# If the model has multiple primary keys, check if this column is one of them
77+
if model.primary_key.collect(&:to_sym).include?(column_name.to_sym)
78+
return true
79+
end
80+
else
81+
# If model has 1 primary key, check if this column is it
82+
if column_name.to_sym == model.primary_key.to_sym
83+
return true
84+
end
85+
end
86+
end
87+
88+
false
89+
end
90+
end
91+
end
92+
end
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# frozen_string_literal: true
2+
3+
module AnnotateRb
4+
module ModelAnnotator
5+
class ColumnAttributesBuilder
6+
# Don't show default value for these column types
7+
NO_DEFAULT_COL_TYPES = %w[json jsonb hstore].freeze
8+
9+
def initialize(column, options, is_primary_key, column_indices)
10+
@column = ColumnWrapper.new(column)
11+
@options = options
12+
@is_primary_key = is_primary_key
13+
@column_indices = column_indices
14+
end
15+
16+
# Get the list of attributes that should be included in the annotation for
17+
# a given column.
18+
def build
19+
column_type = @column.column_type_string
20+
attrs = []
21+
22+
unless @column.default.nil? || hide_default?
23+
schema_default = "default(#{@column.default_string})"
24+
25+
attrs << schema_default
26+
end
27+
28+
if @column.unsigned?
29+
attrs << 'unsigned'
30+
end
31+
32+
if !@column.null
33+
attrs << 'not null'
34+
end
35+
36+
if @is_primary_key
37+
attrs << 'primary key'
38+
end
39+
40+
is_special_type = %w[spatial geometry geography].include?(column_type)
41+
is_decimal_type = column_type == 'decimal'
42+
43+
if !is_decimal_type && !is_special_type
44+
if @column.limit && !@options[:format_yard]
45+
if @column.limit.is_a?(Array)
46+
attrs << "(#{@column.limit.join(', ')})"
47+
end
48+
end
49+
end
50+
51+
# Check out if we got an array column
52+
if @column.array?
53+
attrs << 'is an Array'
54+
end
55+
56+
# Check out if we got a geometric column
57+
# and print the type and SRID
58+
if @column.geometry_type?
59+
attrs << "#{@column.geometry_type}, #{@column.srid}"
60+
elsif @column.geometric_type? && @column.geometric_type.present?
61+
attrs << "#{@column.geometric_type.to_s.downcase}, #{@column.srid}"
62+
end
63+
64+
# Check if the column has indices and print "indexed" if true
65+
# If the index includes another column, print it too.
66+
if @options[:simple_indexes]
67+
# Note: there used to be a klass.table_exists? call here, but removed it as it seemed unnecessary.
68+
69+
sorted_column_indices&.each do |index|
70+
indexed_columns = index.columns.reject { |i| i == @column.name }
71+
72+
if indexed_columns.empty?
73+
attrs << 'indexed'
74+
else
75+
attrs << "indexed => [#{indexed_columns.join(', ')}]"
76+
end
77+
end
78+
end
79+
80+
attrs
81+
end
82+
83+
def sorted_column_indices
84+
# Not sure why there were & safe accessors here, but keeping in for time being.
85+
sorted_indices = @column_indices&.sort_by(&:name)
86+
87+
_sorted_indices = sorted_indices.reject { |ind| ind.columns.is_a?(String) }
88+
end
89+
90+
def hide_default?
91+
excludes =
92+
if @options[:hide_default_column_types].blank?
93+
NO_DEFAULT_COL_TYPES
94+
else
95+
@options[:hide_default_column_types].split(',')
96+
end
97+
98+
excludes.include?(@column.column_type_string)
99+
end
100+
end
101+
end
102+
end
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# frozen_string_literal: true
2+
3+
module AnnotateRb
4+
module ModelAnnotator
5+
class ColumnTypeBuilder
6+
# Don't show limit (#) on these column types
7+
# Example: show "integer" instead of "integer(4)"
8+
NO_LIMIT_COL_TYPES = %w[integer bigint boolean].freeze
9+
10+
def initialize(column, options)
11+
@column = ColumnWrapper.new(column)
12+
@options = options
13+
end
14+
15+
# Returns the formatted column type as a string.
16+
def build
17+
column_type = @column.column_type_string
18+
19+
formatted_column_type = column_type
20+
21+
is_special_type = %w[spatial geometry geography].include?(column_type)
22+
is_decimal_type = column_type == 'decimal'
23+
24+
if is_decimal_type
25+
formatted_column_type = "decimal(#{@column.precision}, #{@column.scale})"
26+
elsif is_special_type
27+
# Do nothing. Kept as a code fragment in case we need to do something here.
28+
else
29+
if @column.limit && !@options[:format_yard]
30+
if !@column.limit.is_a?(Array) && !hide_limit?
31+
formatted_column_type = column_type + "(#{@column.limit})"
32+
end
33+
end
34+
end
35+
36+
formatted_column_type
37+
end
38+
39+
def hide_limit?
40+
excludes =
41+
if @options[:hide_limit_column_types].blank?
42+
NO_LIMIT_COL_TYPES
43+
else
44+
@options[:hide_limit_column_types].split(',')
45+
end
46+
47+
excludes.include?(@column.column_type_string)
48+
end
49+
end
50+
end
51+
end

0 commit comments

Comments
 (0)