annot8 1.0.0

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