annotated 0.0.1

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,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