annotate 2.6.3 → 3.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,169 +1,172 @@
1
+ # rubocop:disable Metrics/ModuleLength
2
+
3
+ require 'bigdecimal'
4
+
5
+ require 'annotate/constants'
6
+ require_relative 'annotate_models/file_patterns'
7
+
1
8
  module AnnotateModels
2
9
  # Annotate Models plugin use this header
3
- COMPAT_PREFIX = "== Schema Info"
4
- COMPAT_PREFIX_MD = "## Schema Info"
5
- PREFIX = "== Schema Information"
6
- PREFIX_MD = "## Schema Information"
7
- END_MARK = "== Schema Information End"
8
- PATTERN = /^\n?# (?:#{COMPAT_PREFIX}|#{COMPAT_PREFIX_MD}).*?\n(#.*\n)*\n*/
9
-
10
- # File.join for windows reverse bar compat?
11
- # I dont use windows, can`t test
12
- UNIT_TEST_DIR = File.join("test", "unit")
13
- SPEC_MODEL_DIR = File.join("spec", "models")
14
- FIXTURE_TEST_DIR = File.join("test", "fixtures")
15
- FIXTURE_SPEC_DIR = File.join("spec", "fixtures")
16
-
17
- # Object Daddy http://github.com/flogic/object_daddy/tree/master
18
- EXEMPLARS_TEST_DIR = File.join("test", "exemplars")
19
- EXEMPLARS_SPEC_DIR = File.join("spec", "exemplars")
20
-
21
- # Machinist http://github.com/notahat/machinist
22
- BLUEPRINTS_TEST_DIR = File.join("test", "blueprints")
23
- BLUEPRINTS_SPEC_DIR = File.join("spec", "blueprints")
24
-
25
- # Factory Girl http://github.com/thoughtbot/factory_girl
26
- FACTORY_GIRL_TEST_DIR = File.join("test", "factories")
27
- FACTORY_GIRL_SPEC_DIR = File.join("spec", "factories")
28
-
29
- # Fabrication https://github.com/paulelliott/fabrication.git
30
- FABRICATORS_TEST_DIR = File.join("test", "fabricators")
31
- FABRICATORS_SPEC_DIR = File.join("spec", "fabricators")
32
-
33
- TEST_PATTERNS = [
34
- File.join(UNIT_TEST_DIR, "%MODEL_NAME%_test.rb"),
35
- File.join(SPEC_MODEL_DIR, "%MODEL_NAME%_spec.rb"),
36
- ]
37
-
38
- FIXTURE_PATTERNS = [
39
- File.join(FIXTURE_TEST_DIR, "%TABLE_NAME%.yml"),
40
- File.join(FIXTURE_SPEC_DIR, "%TABLE_NAME%.yml"),
41
- ]
42
-
43
- FACTORY_PATTERNS = [
44
- File.join(EXEMPLARS_TEST_DIR, "%MODEL_NAME%_exemplar.rb"),
45
- File.join(EXEMPLARS_SPEC_DIR, "%MODEL_NAME%_exemplar.rb"),
46
- File.join(BLUEPRINTS_TEST_DIR, "%MODEL_NAME%_blueprint.rb"),
47
- File.join(BLUEPRINTS_SPEC_DIR, "%MODEL_NAME%_blueprint.rb"),
48
- File.join(FACTORY_GIRL_TEST_DIR, "%MODEL_NAME%_factory.rb"), # (old style)
49
- File.join(FACTORY_GIRL_SPEC_DIR, "%MODEL_NAME%_factory.rb"), # (old style)
50
- File.join(FACTORY_GIRL_TEST_DIR, "%TABLE_NAME%.rb"), # (new style)
51
- File.join(FACTORY_GIRL_SPEC_DIR, "%TABLE_NAME%.rb"), # (new style)
52
- File.join(FABRICATORS_TEST_DIR, "%MODEL_NAME%_fabricator.rb"),
53
- File.join(FABRICATORS_SPEC_DIR, "%MODEL_NAME%_fabricator.rb"),
54
- ]
10
+ COMPAT_PREFIX = '== Schema Info'.freeze
11
+ COMPAT_PREFIX_MD = '## Schema Info'.freeze
12
+ PREFIX = '== Schema Information'.freeze
13
+ PREFIX_MD = '## Schema Information'.freeze
14
+ END_MARK = '== Schema Information End'.freeze
15
+
16
+ SKIP_ANNOTATION_PREFIX = '# -\*- SkipSchemaAnnotations'.freeze
17
+
18
+ MATCHED_TYPES = %w(test fixture factory serializer scaffold controller helper).freeze
55
19
 
56
20
  # Don't show limit (#) on these column types
57
21
  # Example: show "integer" instead of "integer(4)"
58
- NO_LIMIT_COL_TYPES = ["integer", "boolean"]
22
+ NO_LIMIT_COL_TYPES = %w(integer bigint boolean).freeze
23
+
24
+ # Don't show default value for these column types
25
+ NO_DEFAULT_COL_TYPES = %w(json jsonb hstore).freeze
26
+
27
+ INDEX_CLAUSES = {
28
+ unique: {
29
+ default: 'UNIQUE',
30
+ markdown: '_unique_'
31
+ },
32
+ where: {
33
+ default: 'WHERE',
34
+ markdown: '_where_'
35
+ },
36
+ using: {
37
+ default: 'USING',
38
+ markdown: '_using_'
39
+ }
40
+ }.freeze
41
+
42
+ 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
59
43
 
60
44
  class << self
45
+ def annotate_pattern(options = {})
46
+ if options[:wrapper_open]
47
+ 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)*/
48
+ end
49
+ /^(\n|\r\n)?# (?:#{COMPAT_PREFIX}|#{COMPAT_PREFIX_MD}).*?(\n|\r\n)(#.*(\n|\r\n))*(\n|\r\n)*/
50
+ end
51
+
61
52
  def model_dir
62
- @model_dir || "app/models"
53
+ @model_dir.is_a?(Array) ? @model_dir : [@model_dir || 'app/models']
63
54
  end
64
55
 
65
- def model_dir=(dir)
66
- @model_dir = dir
56
+ attr_writer :model_dir
57
+
58
+ def root_dir
59
+ if @root_dir.blank?
60
+ ['']
61
+ elsif @root_dir.is_a?(String)
62
+ @root_dir.split(',')
63
+ else
64
+ @root_dir
65
+ end
66
+ end
67
+
68
+ attr_writer :root_dir
69
+
70
+ def skip_subdirectory_model_load
71
+ # This option is set in options[:skip_subdirectory_model_load]
72
+ # and stops the get_loaded_model method from loading a model from a subdir
73
+
74
+ if @skip_subdirectory_model_load.blank?
75
+ false
76
+ else
77
+ @skip_subdirectory_model_load
78
+ end
79
+ end
80
+
81
+ attr_writer :skip_subdirectory_model_load
82
+
83
+ def get_patterns(options, pattern_types = [])
84
+ current_patterns = []
85
+ root_dir.each do |root_directory|
86
+ Array(pattern_types).each do |pattern_type|
87
+ patterns = FilePatterns.generate(root_directory, pattern_type, options)
88
+
89
+ current_patterns += if pattern_type.to_sym == :additional_file_patterns
90
+ patterns
91
+ else
92
+ patterns.map { |p| p.sub(/^[\/]*/, '') }
93
+ end
94
+ end
95
+ end
96
+ current_patterns
67
97
  end
68
98
 
69
99
  # Simple quoting for the default column value
70
100
  def quote(value)
71
101
  case value
72
- when NilClass then "NULL"
73
- when TrueClass then "TRUE"
74
- when FalseClass then "FALSE"
75
- when Float, Fixnum, Bignum then value.to_s
102
+ when NilClass then 'NULL'
103
+ when TrueClass then 'TRUE'
104
+ when FalseClass then 'FALSE'
105
+ when Float, Integer then value.to_s
76
106
  # BigDecimals need to be output in a non-normalized form and quoted.
77
107
  when BigDecimal then value.to_s('F')
108
+ when Array then value.map { |v| quote(v) }
78
109
  else
79
110
  value.inspect
80
111
  end
81
112
  end
82
113
 
114
+ def schema_default(klass, column)
115
+ quote(klass.column_defaults[column.name])
116
+ end
117
+
118
+ def retrieve_indexes_from_table(klass)
119
+ table_name = klass.table_name
120
+ return [] unless table_name
121
+
122
+ indexes = klass.connection.indexes(table_name)
123
+ return indexes if indexes.any? || !klass.table_name_prefix
124
+
125
+ # Try to search the table without prefix
126
+ table_name_without_prefix = table_name.to_s.sub(klass.table_name_prefix, '')
127
+ klass.connection.indexes(table_name_without_prefix)
128
+ end
129
+
83
130
  # Use the column information in an ActiveRecord class
84
131
  # to create a comment block containing a line for
85
132
  # each column. The line contains the column name,
86
133
  # the type (and length), and any optional attributes
87
134
  def get_schema_info(klass, header, options = {})
88
135
  info = "# #{header}\n"
89
- info<< "#\n"
90
- if(options[:format_markdown])
91
- info<< "# Table name: `#{klass.table_name}`\n"
92
- info<< "#\n"
93
- info<< "# ### Columns\n"
94
- else
95
- info<< "# Table name: #{klass.table_name}\n"
96
- end
97
- info<< "#\n"
136
+ info << get_schema_header_text(klass, options)
98
137
 
99
- max_size = klass.column_names.map{|name| name.size}.max || 0
100
- max_size += options[:format_rdoc] ? 5 : 1
138
+ max_size = max_schema_info_width(klass, options)
101
139
  md_names_overhead = 6
102
140
  md_type_allowance = 18
103
141
  bare_type_allowance = 16
104
142
 
105
- if(options[:format_markdown])
106
- 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' )
107
- info<< "# #{ '-' * ( max_size + md_names_overhead ) } | #{'-' * md_type_allowance} | #{ '-' * 27 }\n"
143
+ if options[:format_markdown]
144
+ 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' )
145
+ info << "# #{ '-' * ( max_size + md_names_overhead ) } | #{'-' * md_type_allowance} | #{ '-' * 27 }\n"
108
146
  end
109
147
 
110
- cols = klass.columns
111
- if options[:ignore_columns]
112
- cols.reject! { |col| col.name.match(/#{options[:ignore_columns]}/) }
113
- end
114
- cols = cols.sort_by(&:name) if(options[:sort])
148
+ cols = columns(klass, options)
115
149
  cols.each do |col|
116
- attrs = []
117
- attrs << "default(#{quote(col.default)})" unless col.default.nil?
118
- attrs << "not null" unless col.null
119
- attrs << "primary key" if klass.primary_key && (klass.primary_key.is_a?(Array) ? klass.primary_key.collect{|c|c.to_sym}.include?(col.name.to_sym) : col.name.to_sym == klass.primary_key.to_sym)
120
-
121
- col_type = (col.type || col.sql_type).to_s
122
- if col_type == "decimal"
123
- col_type << "(#{col.precision}, #{col.scale})"
124
- elsif col_type != "spatial"
125
- if (col.limit)
126
- if col.limit.is_a? Array
127
- attrs << "(#{col.limit.join(', ')})"
128
- else
129
- col_type << "(#{col.limit})" unless NO_LIMIT_COL_TYPES.include?(col_type)
130
- end
131
- end
132
- end
133
-
134
- # Check out if we got an array column
135
- if col.respond_to?(:array) && col.array
136
- attrs << "is an Array"
137
- end
138
-
139
- # Check out if we got a geometric column
140
- # and print the type and SRID
141
- if col.respond_to?(:geometry_type)
142
- attrs << "#{col.geometry_type}, #{col.srid}"
143
- elsif col.respond_to?(:geometric_type) and col.geometric_type.present?
144
- attrs << "#{col.geometric_type.to_s.downcase}, #{col.srid}"
145
- end
146
-
147
- # Check if the column has indices and print "indexed" if true
148
- # If the index includes another column, print it too.
149
- if options[:simple_indexes] && klass.table_exists?# Check out if this column is indexed
150
- indices = klass.connection.indexes(klass.table_name)
151
- if indices = indices.select { |ind| ind.columns.include? col.name }
152
- indices.each do |ind|
153
- ind = ind.columns.reject! { |i| i == col.name }
154
- attrs << (ind.length == 0 ? "indexed" : "indexed => [#{ind.join(", ")}]")
155
- end
156
- end
157
- end
150
+ col_type = get_col_type(col)
151
+ attrs = get_attributes(col, col_type, klass, options)
152
+ col_name = if with_comments?(klass, options) && col.comment
153
+ "#{col.name}(#{col.comment.gsub(/\n/, "\\n")})"
154
+ else
155
+ col.name
156
+ end
158
157
 
159
158
  if options[:format_rdoc]
160
- info << sprintf("# %-#{max_size}.#{max_size}s<tt>%s</tt>", "*#{col.name}*::", attrs.unshift(col_type).join(", ")).rstrip + "\n"
159
+ info << sprintf("# %-#{max_size}.#{max_size}s<tt>%s</tt>", "*#{col_name}*::", attrs.unshift(col_type).join(", ")).rstrip + "\n"
160
+ elsif options[:format_yard]
161
+ info << sprintf("# @!attribute #{col_name}") + "\n"
162
+ 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)
163
+ info << sprintf("# @return [#{ruby_class}]") + "\n"
161
164
  elsif options[:format_markdown]
162
- name_remainder = max_size - col.name.length
165
+ name_remainder = max_size - col_name.length - non_ascii_length(col_name)
163
166
  type_remainder = (md_type_allowance - 2) - col_type.length
164
- info << (sprintf("# **`%s`**%#{name_remainder}s | `%s`%#{type_remainder}s | `%s`", col.name, " ", col_type, " ", attrs.join(", ").rstrip)).gsub('``', ' ').rstrip + "\n"
167
+ info << (sprintf("# **`%s`**%#{name_remainder}s | `%s`%#{type_remainder}s | `%s`", col_name, " ", col_type, " ", attrs.join(", ").rstrip)).gsub('``', ' ').rstrip + "\n"
165
168
  else
166
- info << sprintf("# %-#{max_size}.#{max_size}s:%-#{bare_type_allowance}.#{bare_type_allowance}s %s", col.name, col_type, attrs.join(", ")).rstrip + "\n"
169
+ info << format_default(col_name, max_size, col_type, bare_type_allowance, attrs)
167
170
  end
168
171
  end
169
172
 
@@ -171,6 +174,27 @@ module AnnotateModels
171
174
  info << get_index_info(klass, options)
172
175
  end
173
176
 
177
+ if options[:show_foreign_keys] && klass.table_exists?
178
+ info << get_foreign_key_info(klass, options)
179
+ end
180
+
181
+ info << get_schema_footer_text(klass, options)
182
+ end
183
+
184
+ def get_schema_header_text(klass, options = {})
185
+ info = "#\n"
186
+ if options[:format_markdown]
187
+ info << "# Table name: `#{klass.table_name}`\n"
188
+ info << "#\n"
189
+ info << "# ### Columns\n"
190
+ else
191
+ info << "# Table name: #{klass.table_name}\n"
192
+ end
193
+ info << "#\n"
194
+ end
195
+
196
+ def get_schema_footer_text(_klass, options = {})
197
+ info = ''
174
198
  if options[:format_rdoc]
175
199
  info << "#--\n"
176
200
  info << "# #{END_MARK}\n"
@@ -180,289 +204,714 @@ module AnnotateModels
180
204
  end
181
205
  end
182
206
 
183
- def get_index_info(klass, options={})
184
- if(options[:format_markdown])
185
- index_info = "#\n# ### Indexes\n#\n"
207
+ def get_index_info(klass, options = {})
208
+ index_info = if options[:format_markdown]
209
+ "#\n# ### Indexes\n#\n"
210
+ else
211
+ "#\n# Indexes\n#\n"
212
+ end
213
+
214
+ indexes = retrieve_indexes_from_table(klass)
215
+ return '' if indexes.empty?
216
+
217
+ max_size = indexes.collect{|index| index.name.size}.max + 1
218
+ indexes.sort_by(&:name).each do |index|
219
+ index_info << if options[:format_markdown]
220
+ final_index_string_in_markdown(index)
221
+ else
222
+ final_index_string(index, max_size)
223
+ end
224
+ end
225
+
226
+ index_info
227
+ end
228
+
229
+ def get_col_type(col)
230
+ if (col.respond_to?(:bigint?) && col.bigint?) || /\Abigint\b/ =~ col.sql_type
231
+ 'bigint'
186
232
  else
187
- index_info = "#\n# Indexes\n#\n"
233
+ (col.type || col.sql_type).to_s
188
234
  end
235
+ end
189
236
 
190
- indexes = klass.connection.indexes(klass.table_name)
191
- return "" if indexes.empty?
237
+ def index_columns_info(index)
238
+ Array(index.columns).map do |col|
239
+ if index.try(:orders) && index.orders[col.to_s]
240
+ "#{col} #{index.orders[col.to_s].upcase}"
241
+ else
242
+ col.to_s.gsub("\r", '\r').gsub("\n", '\n')
243
+ end
244
+ end
245
+ end
192
246
 
193
- max_size = indexes.collect{|index| index.name.size}.max + 1
194
- indexes.sort_by{|index| index.name}.each do |index|
195
- if(options[:format_markdown])
196
- index_info << sprintf("# * `%s`%s:\n# * **`%s`**\n", index.name, index.unique ? " (_unique_)" : "", index.columns.join("`**\n# * **`"))
247
+ def index_unique_info(index, format = :default)
248
+ index.unique ? " #{INDEX_CLAUSES[:unique][format]}" : ''
249
+ end
250
+
251
+ def index_where_info(index, format = :default)
252
+ value = index.try(:where).try(:to_s)
253
+ if value.blank?
254
+ ''
255
+ else
256
+ " #{INDEX_CLAUSES[:where][format]} #{value}"
257
+ end
258
+ end
259
+
260
+ def index_using_info(index, format = :default)
261
+ value = index.try(:using) && index.using.try(:to_sym)
262
+ if !value.blank? && value != :btree
263
+ " #{INDEX_CLAUSES[:using][format]} #{value}"
264
+ else
265
+ ''
266
+ end
267
+ end
268
+
269
+ def final_index_string_in_markdown(index)
270
+ details = sprintf(
271
+ "%s%s%s",
272
+ index_unique_info(index, :markdown),
273
+ index_where_info(index, :markdown),
274
+ index_using_info(index, :markdown)
275
+ ).strip
276
+ details = " (#{details})" unless details.blank?
277
+
278
+ sprintf(
279
+ "# * `%s`%s:\n# * **`%s`**\n",
280
+ index.name,
281
+ details,
282
+ index_columns_info(index).join("`**\n# * **`")
283
+ )
284
+ end
285
+
286
+ def final_index_string(index, max_size)
287
+ sprintf(
288
+ "# %-#{max_size}.#{max_size}s %s%s%s%s",
289
+ index.name,
290
+ "(#{index_columns_info(index).join(',')})",
291
+ index_unique_info(index),
292
+ index_where_info(index),
293
+ index_using_info(index)
294
+ ).rstrip + "\n"
295
+ end
296
+
297
+ def hide_limit?(col_type, options)
298
+ excludes =
299
+ if options[:hide_limit_column_types].blank?
300
+ NO_LIMIT_COL_TYPES
197
301
  else
198
- index_info << sprintf("# %-#{max_size}.#{max_size}s %s %s", index.name, "(#{index.columns.join(",")})", index.unique ? "UNIQUE" : "").rstrip + "\n"
302
+ options[:hide_limit_column_types].split(',')
199
303
  end
304
+
305
+ excludes.include?(col_type)
306
+ end
307
+
308
+ def hide_default?(col_type, options)
309
+ excludes =
310
+ if options[:hide_default_column_types].blank?
311
+ NO_DEFAULT_COL_TYPES
312
+ else
313
+ options[:hide_default_column_types].split(',')
314
+ end
315
+
316
+ excludes.include?(col_type)
317
+ end
318
+
319
+ def get_foreign_key_info(klass, options = {})
320
+ fk_info = if options[:format_markdown]
321
+ "#\n# ### Foreign Keys\n#\n"
322
+ else
323
+ "#\n# Foreign Keys\n#\n"
324
+ end
325
+
326
+ return '' unless klass.connection.respond_to?(:supports_foreign_keys?) &&
327
+ klass.connection.supports_foreign_keys? && klass.connection.respond_to?(:foreign_keys)
328
+
329
+ foreign_keys = klass.connection.foreign_keys(klass.table_name)
330
+ return '' if foreign_keys.empty?
331
+
332
+ format_name = lambda do |fk|
333
+ return fk.options[:column] if fk.name.blank?
334
+ options[:show_complete_foreign_keys] ? fk.name : fk.name.gsub(/(?<=^fk_rails_)[0-9a-f]{10}$/, '...')
335
+ end
336
+
337
+ max_size = foreign_keys.map(&format_name).map(&:size).max + 1
338
+ foreign_keys.sort_by {|fk| [format_name.call(fk), fk.column]}.each do |fk|
339
+ ref_info = "#{fk.column} => #{fk.to_table}.#{fk.primary_key}"
340
+ constraints_info = ''
341
+ constraints_info += "ON DELETE => #{fk.on_delete} " if fk.on_delete
342
+ constraints_info += "ON UPDATE => #{fk.on_update} " if fk.on_update
343
+ constraints_info.strip!
344
+
345
+ fk_info << if options[:format_markdown]
346
+ sprintf("# * `%s`%s:\n# * **`%s`**\n", format_name.call(fk), constraints_info.blank? ? '' : " (_#{constraints_info}_)", ref_info)
347
+ else
348
+ sprintf("# %-#{max_size}.#{max_size}s %s %s", format_name.call(fk), "(#{ref_info})", constraints_info).rstrip + "\n"
349
+ end
200
350
  end
201
- return index_info
351
+
352
+ fk_info
202
353
  end
203
354
 
204
355
  # Add a schema block to a file. If the file already contains
205
- # a schema info block (a comment starting with "== Schema Information"), check if it
206
- # matches the block that is already there. If so, leave it be. If not, remove the old
207
- # info block and write a new one.
208
- # Returns true or false depending on whether the file was modified.
356
+ # a schema info block (a comment starting with "== Schema Information"),
357
+ # check if it matches the block that is already there. If so, leave it be.
358
+ # If not, remove the old info block and write a new one.
359
+ #
360
+ # == Returns:
361
+ # true or false depending on whether the file was modified.
209
362
  #
210
363
  # === Options (opts)
211
364
  # :force<Symbol>:: whether to update the file even if it doesn't seem to need it.
212
365
  # :position_in_*<Symbol>:: where to place the annotated section in fixture or model file,
213
- # :before or :after. Default is :before.
366
+ # :before, :top, :after or :bottom. Default is :before.
214
367
  #
215
- def annotate_one_file(file_name, info_block, position, options={})
216
- if File.exist?(file_name)
217
- old_content = File.read(file_name)
218
- return false if(old_content =~ /# -\*- SkipSchemaAnnotations.*\n/)
219
-
220
- # Ignore the Schema version line because it changes with each migration
221
- header_pattern = /(^# Table name:.*?\n(#.*[\r]?\n)*[\r]?)/
222
- old_header = old_content.match(header_pattern).to_s
223
- new_header = info_block.match(header_pattern).to_s
368
+ def annotate_one_file(file_name, info_block, position, options = {})
369
+ return false unless File.exist?(file_name)
370
+ old_content = File.read(file_name)
371
+ return false if old_content =~ /#{SKIP_ANNOTATION_PREFIX}.*\n/
372
+
373
+ # Ignore the Schema version line because it changes with each migration
374
+ header_pattern = /(^# Table name:.*?\n(#.*[\r]?\n)*[\r]?)/
375
+ old_header = old_content.match(header_pattern).to_s
376
+ new_header = info_block.match(header_pattern).to_s
377
+
378
+ column_pattern = /^#[\t ]+[\w\*\.`]+[\t ]+.+$/
379
+ old_columns = old_header && old_header.scan(column_pattern).sort
380
+ new_columns = new_header && new_header.scan(column_pattern).sort
381
+
382
+ return false if old_columns == new_columns && !options[:force]
383
+
384
+ abort "annotate error. #{file_name} needs to be updated, but annotate was run with `--frozen`." if options[:frozen]
385
+
386
+ # Replace inline the old schema info with the new schema info
387
+ wrapper_open = options[:wrapper_open] ? "# #{options[:wrapper_open]}\n" : ""
388
+ wrapper_close = options[:wrapper_close] ? "# #{options[:wrapper_close]}\n" : ""
389
+ wrapped_info_block = "#{wrapper_open}#{info_block}#{wrapper_close}"
390
+
391
+ old_annotation = old_content.match(annotate_pattern(options)).to_s
392
+
393
+ # if there *was* no old schema info or :force was passed, we simply
394
+ # need to insert it in correct position
395
+ if old_annotation.empty? || options[:force]
396
+ magic_comments_block = magic_comments_as_string(old_content)
397
+ old_content.gsub!(MAGIC_COMMENT_MATCHER, '')
398
+ old_content.sub!(annotate_pattern(options), '')
399
+
400
+ new_content = if %w(after bottom).include?(options[position].to_s)
401
+ magic_comments_block + (old_content.rstrip + "\n\n" + wrapped_info_block)
402
+ elsif magic_comments_block.empty?
403
+ magic_comments_block + wrapped_info_block + old_content.lstrip
404
+ else
405
+ magic_comments_block + "\n" + wrapped_info_block + old_content.lstrip
406
+ end
407
+ else
408
+ # replace the old annotation with the new one
224
409
 
225
- column_pattern = /^#[\t ]+\w+[\t ]+.+$/
226
- old_columns = old_header && old_header.scan(column_pattern).sort
227
- new_columns = new_header && new_header.scan(column_pattern).sort
410
+ # keep the surrounding whitespace the same
411
+ space_match = old_annotation.match(/\A(?<start>\s*).*?\n(?<end>\s*)\z/m)
412
+ new_annotation = space_match[:start] + wrapped_info_block + space_match[:end]
228
413
 
229
- encoding = Regexp.new(/(^#\s*encoding:.*\n)|(^# coding:.*\n)|(^# -\*- coding:.*\n)|(^# -\*- encoding\s?:.*\n)/)
230
- encoding_header = old_content.match(encoding).to_s
414
+ new_content = old_content.sub(annotate_pattern(options), new_annotation)
415
+ end
231
416
 
232
- if old_columns == new_columns && !options[:force]
233
- return false
234
- else
235
- # Replace inline the old schema info with the new schema info
236
- new_content = old_content.sub(PATTERN, info_block + "\n")
417
+ File.open(file_name, 'wb') { |f| f.puts new_content }
418
+ true
419
+ end
237
420
 
238
- if new_content.end_with? (info_block + "\n")
239
- new_content = old_content.sub(PATTERN, "\n" + info_block)
240
- end
241
-
242
- # if there *was* no old schema info (no substitution happened) or :force was passed,
243
- # we simply need to insert it in correct position
244
- if new_content == old_content || options[:force]
245
- old_content.sub!(encoding, '')
246
- old_content.sub!(PATTERN, '')
247
-
248
- new_content = options[position].to_s == 'after' ?
249
- (encoding_header + (old_content.rstrip + "\n\n" + info_block)) :
250
- (encoding_header + info_block + "\n" + old_content)
251
- end
421
+ def magic_comments_as_string(content)
422
+ magic_comments = content.scan(MAGIC_COMMENT_MATCHER).flatten.compact
252
423
 
253
- File.open(file_name, "wb") { |f| f.puts new_content }
254
- return true
255
- end
424
+ if magic_comments.any?
425
+ magic_comments.join
256
426
  else
257
- return false
427
+ ''
258
428
  end
259
429
  end
260
430
 
261
- def remove_annotation_of_file(file_name)
431
+ def remove_annotation_of_file(file_name, options = {})
262
432
  if File.exist?(file_name)
263
433
  content = File.read(file_name)
434
+ return false if content =~ /#{SKIP_ANNOTATION_PREFIX}.*\n/
264
435
 
265
- content.sub!(PATTERN, '')
436
+ wrapper_open = options[:wrapper_open] ? "# #{options[:wrapper_open]}\n" : ''
437
+ content.sub!(/(#{wrapper_open})?#{annotate_pattern(options)}/, '')
266
438
 
267
- File.open(file_name, "wb") { |f| f.puts content }
439
+ File.open(file_name, 'wb') { |f| f.puts content }
268
440
 
269
- return true
441
+ true
270
442
  else
271
- return false
443
+ false
272
444
  end
273
445
  end
274
446
 
447
+ def matched_types(options)
448
+ types = MATCHED_TYPES.dup
449
+ types << 'admin' if options[:active_admin] =~ Annotate::Constants::TRUE_RE && !types.include?('admin')
450
+ types << 'additional_file_patterns' if options[:additional_file_patterns].present?
451
+
452
+ types
453
+ end
454
+
275
455
  # Given the name of an ActiveRecord class, create a schema
276
456
  # info block (basically a comment containing information
277
457
  # on the columns and their types) and put it at the front
278
458
  # of the model and fixture source files.
279
- # Returns true or false depending on whether the source
280
- # files were modified.
281
459
  #
282
460
  # === Options (opts)
283
461
  # :position_in_class<Symbol>:: where to place the annotated section in model file
284
462
  # :position_in_test<Symbol>:: where to place the annotated section in test/spec file(s)
285
463
  # :position_in_fixture<Symbol>:: where to place the annotated section in fixture file
286
464
  # :position_in_factory<Symbol>:: where to place the annotated section in factory file
465
+ # :position_in_serializer<Symbol>:: where to place the annotated section in serializer file
287
466
  # :exclude_tests<Symbol>:: whether to skip modification of test/spec files
288
467
  # :exclude_fixtures<Symbol>:: whether to skip modification of fixture files
289
468
  # :exclude_factories<Symbol>:: whether to skip modification of factory files
469
+ # :exclude_serializers<Symbol>:: whether to skip modification of serializer files
470
+ # :exclude_scaffolds<Symbol>:: whether to skip modification of scaffold files
471
+ # :exclude_controllers<Symbol>:: whether to skip modification of controller files
472
+ # :exclude_helpers<Symbol>:: whether to skip modification of helper files
473
+ # :exclude_sti_subclasses<Symbol>:: whether to skip modification of files for STI subclasses
474
+ #
475
+ # == Returns:
476
+ # an array of file names that were annotated.
290
477
  #
291
- def annotate(klass, file, header, options={})
478
+ def annotate(klass, file, header, options = {})
292
479
  begin
480
+ klass.reset_column_information
293
481
  info = get_schema_info(klass, header, options)
294
- did_annotate = false
295
482
  model_name = klass.name.underscore
296
483
  table_name = klass.table_name
297
- model_file_name = File.join(model_dir, file)
484
+ model_file_name = File.join(file)
485
+ annotated = []
298
486
 
299
487
  if annotate_one_file(model_file_name, info, :position_in_class, options_with_position(options, :position_in_class))
300
- did_annotate = true
488
+ annotated << model_file_name
301
489
  end
302
490
 
303
- unless options[:exclude_tests]
304
- did_annotate = TEST_PATTERNS.
305
- map { |file| resolve_filename(file, model_name, table_name) }.
306
- map { |file| annotate_one_file(file, info, :position_in_test, options_with_position(options, :position_in_test)) }.
307
- detect { |result| result } || did_annotate
308
- end
491
+ matched_types(options).each do |key|
492
+ exclusion_key = "exclude_#{key.pluralize}".to_sym
493
+ position_key = "position_in_#{key}".to_sym
309
494
 
310
- unless options[:exclude_fixtures]
311
- did_annotate = FIXTURE_PATTERNS.
312
- map { |file| resolve_filename(file, model_name, table_name) }.
313
- map { |file| annotate_one_file(file, info, :position_in_fixture, options_with_position(options, :position_in_fixture)) }.
314
- detect { |result| result } || did_annotate
315
- end
495
+ # Same options for active_admin models
496
+ if key == 'admin'
497
+ exclusion_key = 'exclude_class'.to_sym
498
+ position_key = 'position_in_class'.to_sym
499
+ end
316
500
 
317
- unless options[:exclude_factories]
318
- did_annotate = FACTORY_PATTERNS.
319
- map { |file| resolve_filename(file, model_name, table_name) }.
320
- map { |file| annotate_one_file(file, info, :position_in_factory, options_with_position(options, :position_in_factory)) }.
321
- detect { |result| result } || did_annotate
322
- end
501
+ next if options[exclusion_key]
323
502
 
324
- return did_annotate
325
- rescue Exception => e
326
- puts "Unable to annotate #{file}: #{e.message}"
327
- puts "\t" + e.backtrace.join("\n\t") if options[:trace]
503
+ get_patterns(options, key)
504
+ .map { |f| resolve_filename(f, model_name, table_name) }
505
+ .map { |f| expand_glob_into_files(f) }
506
+ .flatten
507
+ .each do |f|
508
+ if annotate_one_file(f, info, position_key, options_with_position(options, position_key))
509
+ annotated << f
510
+ end
511
+ end
512
+ end
513
+ rescue StandardError => e
514
+ $stderr.puts "Unable to annotate #{file}: #{e.message}"
515
+ $stderr.puts "\t" + e.backtrace.join("\n\t") if options[:trace]
328
516
  end
517
+
518
+ annotated
329
519
  end
330
520
 
331
521
  # position = :position_in_fixture or :position_in_class
332
522
  def options_with_position(options, position_in)
333
- options.merge(:position=>(options[position_in] || options[:position]))
523
+ options.merge(position: (options[position_in] || options[:position]))
334
524
  end
335
525
 
336
- # Return a list of the model files to annotate. If we have
337
- # command line arguments, they're assumed to be either
338
- # the underscore or CamelCase versions of model names.
339
- # Otherwise we take all the model files in the
340
- # model_dir directory.
526
+ # Return a list of the model files to annotate.
527
+ # If we have command line arguments, they're assumed to the path
528
+ # of model files from root dir. Otherwise we take all the model files
529
+ # in the model_dir directory.
341
530
  def get_model_files(options)
342
- if(!options[:is_rake])
343
- models = ARGV.dup
344
- models.shift
345
- else
346
- models = []
347
- end
348
- models.reject!{|m| m.match(/^(.*)=/)}
349
- if models.empty?
350
- begin
351
- Dir.chdir(model_dir) do
352
- models = if options[:ignore_model_sub_dir]
353
- Dir["*.rb"]
354
- else
355
- Dir["**/*.rb"]
356
- end
357
- end
358
- rescue SystemCallError
359
- puts "No models found in directory '#{model_dir}'."
360
- puts "Either specify models on the command line, or use the --model-dir option."
361
- puts "Call 'annotate --help' for more info."
362
- exit 1
531
+ model_files = []
532
+
533
+ model_files = list_model_files_from_argument unless options[:is_rake]
534
+
535
+ return model_files unless model_files.empty?
536
+
537
+ model_dir.each do |dir|
538
+ Dir.chdir(dir) do
539
+ list = if options[:ignore_model_sub_dir]
540
+ Dir["*.rb"].map { |f| [dir, f] }
541
+ else
542
+ Dir["**/*.rb"].reject { |f| f["concerns/"] }.map { |f| [dir, f] }
543
+ end
544
+ model_files.concat(list)
363
545
  end
364
546
  end
365
- models
547
+
548
+ model_files
549
+ rescue SystemCallError
550
+ $stderr.puts "No models found in directory '#{model_dir.join("', '")}'."
551
+ $stderr.puts "Either specify models on the command line, or use the --model-dir option."
552
+ $stderr.puts "Call 'annotate --help' for more info."
553
+ exit 1
554
+ end
555
+
556
+ def list_model_files_from_argument
557
+ return [] if ARGV.empty?
558
+
559
+ specified_files = ARGV.map { |file| File.expand_path(file) }
560
+
561
+ model_files = model_dir.flat_map do |dir|
562
+ absolute_dir_path = File.expand_path(dir)
563
+ specified_files
564
+ .find_all { |file| file.start_with?(absolute_dir_path) }
565
+ .map { |file| [dir, file.sub("#{absolute_dir_path}/", '')] }
566
+ end
567
+
568
+ if model_files.size != specified_files.size
569
+ puts "The specified file could not be found in directory '#{model_dir.join("', '")}'."
570
+ puts "Call 'annotate --help' for more info."
571
+ exit 1
572
+ end
573
+
574
+ model_files
366
575
  end
576
+ private :list_model_files_from_argument
367
577
 
368
578
  # Retrieve the classes belonging to the model names we're asked to process
369
579
  # Check for namespaced models in subdirectories as well as models
370
580
  # in subdirectories without namespacing.
371
581
  def get_model_class(file)
372
- # this is for non-rails projects, which don't get Rails auto-require magic
373
- require File.expand_path("#{model_dir}/#{file}")
374
582
  model_path = file.gsub(/\.rb$/, '')
375
- get_loaded_model(model_path) || get_loaded_model(model_path.split('/').last)
583
+ model_dir.each { |dir| model_path = model_path.gsub(/^#{dir}/, '').gsub(/^\//, '') }
584
+ begin
585
+ get_loaded_model(model_path, file) || raise(BadModelFileError.new)
586
+ rescue LoadError
587
+ # this is for non-rails projects, which don't get Rails auto-require magic
588
+ file_path = File.expand_path(file)
589
+ if File.file?(file_path) && Kernel.require(file_path)
590
+ retry
591
+ elsif model_path =~ /\//
592
+ model_path = model_path.split('/')[1..-1].join('/').to_s
593
+ retry
594
+ else
595
+ raise
596
+ end
597
+ end
598
+ end
599
+
600
+ # Retrieve loaded model class
601
+ def get_loaded_model(model_path, file)
602
+ unless skip_subdirectory_model_load
603
+ loaded_model_class = get_loaded_model_by_path(model_path)
604
+ return loaded_model_class if loaded_model_class
605
+ end
606
+
607
+ # We cannot get loaded model when `model_path` is loaded by Rails
608
+ # auto_load/eager_load paths. Try all possible model paths one by one.
609
+ absolute_file = File.expand_path(file)
610
+ model_paths =
611
+ $LOAD_PATH.map(&:to_s)
612
+ .select { |path| absolute_file.include?(path) }
613
+ .map { |path| absolute_file.sub(path, '').sub(/\.rb$/, '').sub(/^\//, '') }
614
+ model_paths
615
+ .map { |path| get_loaded_model_by_path(path) }
616
+ .find { |loaded_model| !loaded_model.nil? }
376
617
  end
377
618
 
378
619
  # Retrieve loaded model class by path to the file where it's supposed to be defined.
379
- def get_loaded_model(model_path)
380
- ObjectSpace.each_object(::Class).
381
- select do |c|
382
- Class === c and # note: we use === to avoid a bug in activesupport 2.3.14 OptionMerger vs. is_a?
383
- c.ancestors.respond_to?(:include?) and # to fix FactoryGirl bug, see https://github.com/ctran/annotate_models/pull/82
384
- c.ancestors.include?(ActiveRecord::Base)
385
- end.
386
- detect { |c| ActiveSupport::Inflector.underscore(c) == model_path }
620
+ def get_loaded_model_by_path(model_path)
621
+ ActiveSupport::Inflector.constantize(ActiveSupport::Inflector.camelize(model_path))
622
+ rescue StandardError, LoadError
623
+ # Revert to the old way but it is not really robust
624
+ ObjectSpace.each_object(::Class)
625
+ .select do |c|
626
+ Class === c && # note: we use === to avoid a bug in activesupport 2.3.14 OptionMerger vs. is_a?
627
+ c.ancestors.respond_to?(:include?) && # to fix FactoryGirl bug, see https://github.com/ctran/annotate_models/pull/82
628
+ c.ancestors.include?(ActiveRecord::Base)
629
+ end.detect { |c| ActiveSupport::Inflector.underscore(c.to_s) == model_path }
630
+ end
631
+
632
+ def parse_options(options = {})
633
+ self.model_dir = split_model_dir(options[:model_dir]) if options[:model_dir]
634
+ self.root_dir = options[:root_dir] if options[:root_dir]
635
+ self.skip_subdirectory_model_load = options[:skip_subdirectory_model_load].present?
636
+ end
637
+
638
+ def split_model_dir(option_value)
639
+ option_value = option_value.is_a?(Array) ? option_value : option_value.split(',')
640
+ option_value.map(&:strip).reject(&:empty?)
387
641
  end
388
642
 
389
643
  # We're passed a name of things that might be
390
644
  # ActiveRecord models. If we can find the class, and
391
645
  # if its a subclass of ActiveRecord::Base,
392
646
  # then pass it to the associated block
393
- def do_annotations(options={})
394
- header = options[:format_markdown] ? PREFIX_MD.dup : PREFIX.dup
647
+ def do_annotations(options = {})
648
+ parse_options(options)
395
649
 
396
- if options[:include_version]
397
- version = ActiveRecord::Migrator.current_version rescue 0
398
- if version > 0
399
- header << "\n# Schema version: #{version}"
400
- end
650
+ header = options[:format_markdown] ? PREFIX_MD.dup : PREFIX.dup
651
+ version = ActiveRecord::Migrator.current_version rescue 0
652
+ if options[:include_version] && version > 0
653
+ header << "\n# Schema version: #{version}"
401
654
  end
402
655
 
403
- self.model_dir = options[:model_dir] if options[:model_dir]
404
-
405
656
  annotated = []
406
- get_model_files(options).each do |file|
407
- annotate_model_file(annotated, file, header, options)
657
+ get_model_files(options).each do |path, filename|
658
+ annotate_model_file(annotated, File.join(path, filename), header, options)
408
659
  end
660
+
409
661
  if annotated.empty?
410
- puts "Nothing annotated."
662
+ puts 'Model files unchanged.'
411
663
  else
412
664
  puts "Annotated (#{annotated.length}): #{annotated.join(', ')}"
413
665
  end
414
666
  end
415
667
 
668
+ def expand_glob_into_files(glob)
669
+ Dir.glob(glob)
670
+ end
671
+
416
672
  def annotate_model_file(annotated, file, header, options)
417
673
  begin
674
+ return false if /#{SKIP_ANNOTATION_PREFIX}.*/ =~ (File.exist?(file) ? File.read(file) : '')
418
675
  klass = get_model_class(file)
419
- if klass && klass < ActiveRecord::Base && !klass.abstract_class? && klass.table_exists?
420
- if annotate(klass, file, header, options)
421
- annotated << klass
422
- end
676
+ do_annotate = klass.is_a?(Class) &&
677
+ klass < ActiveRecord::Base &&
678
+ (!options[:exclude_sti_subclasses] || !(klass.superclass < ActiveRecord::Base && klass.table_name == klass.superclass.table_name)) &&
679
+ !klass.abstract_class? &&
680
+ klass.table_exists?
681
+
682
+ annotated.concat(annotate(klass, file, header, options)) if do_annotate
683
+ rescue BadModelFileError => e
684
+ unless options[:ignore_unknown_models]
685
+ $stderr.puts "Unable to annotate #{file}: #{e.message}"
686
+ $stderr.puts "\t" + e.backtrace.join("\n\t") if options[:trace]
423
687
  end
424
- rescue Exception => e
425
- puts "Unable to annotate #{file}: #{e.message}"
426
- puts "\t" + e.backtrace.join("\n\t") if options[:trace]
688
+ rescue StandardError => e
689
+ $stderr.puts "Unable to annotate #{file}: #{e.message}"
690
+ $stderr.puts "\t" + e.backtrace.join("\n\t") if options[:trace]
427
691
  end
428
692
  end
429
693
 
430
- def remove_annotations(options={})
694
+ def remove_annotations(options = {})
695
+ parse_options(options)
431
696
 
432
- self.model_dir = options[:model_dir] if options[:model_dir]
433
697
  deannotated = []
434
698
  deannotated_klass = false
435
699
  get_model_files(options).each do |file|
700
+ file = File.join(file)
436
701
  begin
437
702
  klass = get_model_class(file)
438
703
  if klass < ActiveRecord::Base && !klass.abstract_class?
439
704
  model_name = klass.name.underscore
440
705
  table_name = klass.table_name
441
- model_file_name = File.join(model_dir, file)
442
- deannotated_klass = true if(remove_annotation_of_file(model_file_name))
443
-
444
- (TEST_PATTERNS + FIXTURE_PATTERNS + FACTORY_PATTERNS).
445
- map { |file| resolve_filename(file, model_name, table_name) }.
446
- each do |file|
447
- if File.exist?(file)
448
- remove_annotation_of_file(file)
706
+ model_file_name = file
707
+ deannotated_klass = true if remove_annotation_of_file(model_file_name, options)
708
+
709
+ get_patterns(options, matched_types(options))
710
+ .map { |f| resolve_filename(f, model_name, table_name) }
711
+ .each do |f|
712
+ if File.exist?(f)
713
+ remove_annotation_of_file(f, options)
449
714
  deannotated_klass = true
450
715
  end
451
716
  end
452
717
  end
453
- deannotated << klass if(deannotated_klass)
454
- rescue Exception => e
455
- puts "Unable to deannotate #{file}: #{e.message}"
456
- puts "\t" + e.backtrace.join("\n\t") if options[:trace]
718
+ deannotated << klass if deannotated_klass
719
+ rescue StandardError => e
720
+ $stderr.puts "Unable to deannotate #{File.join(file)}: #{e.message}"
721
+ $stderr.puts "\t" + e.backtrace.join("\n\t") if options[:trace]
457
722
  end
458
723
  end
459
724
  puts "Removed annotations from: #{deannotated.join(', ')}"
460
725
  end
461
726
 
462
727
  def resolve_filename(filename_template, model_name, table_name)
463
- return filename_template.
464
- gsub('%MODEL_NAME%', model_name).
465
- gsub('%TABLE_NAME%', table_name || model_name.pluralize)
728
+ filename_template
729
+ .gsub('%MODEL_NAME%', model_name)
730
+ .gsub('%PLURALIZED_MODEL_NAME%', model_name.pluralize)
731
+ .gsub('%TABLE_NAME%', table_name || model_name.pluralize)
732
+ end
733
+
734
+ def classified_sort(cols)
735
+ rest_cols = []
736
+ timestamps = []
737
+ associations = []
738
+ id = nil
739
+
740
+ cols.each do |c|
741
+ if c.name.eql?('id')
742
+ id = c
743
+ elsif c.name.eql?('created_at') || c.name.eql?('updated_at')
744
+ timestamps << c
745
+ elsif c.name[-3, 3].eql?('_id')
746
+ associations << c
747
+ else
748
+ rest_cols << c
749
+ end
750
+ end
751
+ [rest_cols, timestamps, associations].each { |a| a.sort_by!(&:name) }
752
+
753
+ ([id] << rest_cols << timestamps << associations).flatten.compact
754
+ end
755
+
756
+ private
757
+
758
+ def with_comments?(klass, options)
759
+ options[:with_comment] &&
760
+ klass.columns.first.respond_to?(:comment) &&
761
+ klass.columns.any? { |col| !col.comment.nil? }
762
+ end
763
+
764
+ def max_schema_info_width(klass, options)
765
+ cols = columns(klass, options)
766
+
767
+ if with_comments?(klass, options)
768
+ max_size = cols.map do |column|
769
+ column.name.size + (column.comment ? width(column.comment) : 0)
770
+ end.max || 0
771
+ max_size += 2
772
+ else
773
+ max_size = cols.map(&:name).map(&:size).max
774
+ end
775
+ max_size += options[:format_rdoc] ? 5 : 1
776
+
777
+ max_size
778
+ end
779
+
780
+ def format_default(col_name, max_size, col_type, bare_type_allowance, attrs)
781
+ sprintf("# %s:%s %s", mb_chars_ljust(col_name, max_size), mb_chars_ljust(col_type, bare_type_allowance), attrs.join(", ")).rstrip + "\n"
782
+ end
783
+
784
+ def width(string)
785
+ string.chars.inject(0) { |acc, elem| acc + (elem.bytesize == 3 ? 2 : 1) }
786
+ end
787
+
788
+ def mb_chars_ljust(string, length)
789
+ string = string.to_s
790
+ padding = length - width(string)
791
+ if padding > 0
792
+ string + (' ' * padding)
793
+ else
794
+ string[0..length-1]
795
+ end
796
+ end
797
+
798
+ def non_ascii_length(string)
799
+ string.to_s.chars.reject(&:ascii_only?).length
800
+ end
801
+
802
+ def map_col_type_to_ruby_classes(col_type)
803
+ case col_type
804
+ when 'integer' then Integer.to_s
805
+ when 'float' then Float.to_s
806
+ when 'decimal' then BigDecimal.to_s
807
+ when 'datetime', 'timestamp', 'time' then Time.to_s
808
+ when 'date' then Date.to_s
809
+ when 'text', 'string', 'binary', 'inet', 'uuid' then String.to_s
810
+ when 'json', 'jsonb' then Hash.to_s
811
+ when 'boolean' then 'Boolean'
812
+ end
813
+ end
814
+
815
+ def columns(klass, options)
816
+ cols = klass.columns
817
+ cols += translated_columns(klass)
818
+
819
+ if ignore_columns = options[:ignore_columns]
820
+ cols = cols.reject do |col|
821
+ col.name.match(/#{ignore_columns}/)
822
+ end
823
+ end
824
+
825
+ cols = cols.sort_by(&:name) if options[:sort]
826
+ cols = classified_sort(cols) if options[:classified_sort]
827
+
828
+ cols
829
+ end
830
+
831
+ ##
832
+ # Add columns managed by the globalize gem if this gem is being used.
833
+ def translated_columns(klass)
834
+ return [] unless klass.respond_to? :translation_class
835
+
836
+ ignored_cols = ignored_translation_table_colums(klass)
837
+ klass.translation_class.columns.reject do |col|
838
+ ignored_cols.include? col.name.to_sym
839
+ end
840
+ end
841
+
842
+ ##
843
+ # These are the columns that the globalize gem needs to work but
844
+ # are not necessary for the models to be displayed as annotations.
845
+ def ignored_translation_table_colums(klass)
846
+ # Construct the foreign column name in the translations table
847
+ # eg. Model: Car, foreign column name: car_id
848
+ foreign_column_name = [
849
+ klass.table_name.to_s.singularize,
850
+ '_id'
851
+ ].join.to_sym
852
+
853
+ [
854
+ :id,
855
+ :created_at,
856
+ :updated_at,
857
+ :locale,
858
+ foreign_column_name
859
+ ]
860
+ end
861
+
862
+ ##
863
+ # Get the list of attributes that should be included in the annotation for
864
+ # a given column.
865
+ def get_attributes(column, column_type, klass, options)
866
+ attrs = []
867
+ attrs << "default(#{schema_default(klass, column)})" unless column.default.nil? || hide_default?(column_type, options)
868
+ attrs << 'unsigned' if column.respond_to?(:unsigned?) && column.unsigned?
869
+ attrs << 'not null' unless column.null
870
+ 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)
871
+
872
+ if column_type == 'decimal'
873
+ column_type << "(#{column.precision}, #{column.scale})"
874
+ elsif !%w[spatial geometry geography].include?(column_type)
875
+ if column.limit && !options[:format_yard]
876
+ if column.limit.is_a? Array
877
+ attrs << "(#{column.limit.join(', ')})"
878
+ else
879
+ column_type << "(#{column.limit})" unless hide_limit?(column_type, options)
880
+ end
881
+ end
882
+ end
883
+
884
+ # Check out if we got an array column
885
+ attrs << 'is an Array' if column.respond_to?(:array) && column.array
886
+
887
+ # Check out if we got a geometric column
888
+ # and print the type and SRID
889
+ if column.respond_to?(:geometry_type)
890
+ attrs << [column.geometry_type, column.try(:srid)].compact.join(', ')
891
+ elsif column.respond_to?(:geometric_type) && column.geometric_type.present?
892
+ attrs << [column.geometric_type.to_s.downcase, column.try(:srid)].compact.join(', ')
893
+ end
894
+
895
+ # Check if the column has indices and print "indexed" if true
896
+ # If the index includes another column, print it too.
897
+ if options[:simple_indexes] && klass.table_exists?# Check out if this column is indexed
898
+ indices = retrieve_indexes_from_table(klass)
899
+ if indices = indices.select { |ind| ind.columns.include? column.name }
900
+ indices.sort_by(&:name).each do |ind|
901
+ next if ind.columns.is_a?(String)
902
+ ind = ind.columns.reject! { |i| i == column.name }
903
+ attrs << (ind.empty? ? "indexed" : "indexed => [#{ind.join(", ")}]")
904
+ end
905
+ end
906
+ end
907
+
908
+ attrs
909
+ end
910
+ end
911
+
912
+ class BadModelFileError < LoadError
913
+ def to_s
914
+ "file doesn't contain a valid model class"
466
915
  end
467
916
  end
468
917
  end