annotated 0.0.1

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