annotaterb 4.22.0 → 4.24.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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -0
  3. data/README.md +156 -4
  4. data/VERSION +1 -1
  5. data/lib/annotate_rb/helper.rb +24 -1
  6. data/lib/annotate_rb/model_annotator/annotated_file/generator.rb +34 -12
  7. data/lib/annotate_rb/model_annotator/annotated_file/updater.rb +14 -1
  8. data/lib/annotate_rb/model_annotator/annotation/annotation_builder.rb +9 -1
  9. data/lib/annotate_rb/model_annotator/annotation_decider.rb +3 -0
  10. data/lib/annotate_rb/model_annotator/annotation_diff_generator.rb +9 -4
  11. data/lib/annotate_rb/model_annotator/column_annotation/attributes_builder.rb +7 -0
  12. data/lib/annotate_rb/model_annotator/column_annotation/column_component.rb +2 -2
  13. data/lib/annotate_rb/model_annotator/column_annotation/column_wrapper.rb +4 -0
  14. data/lib/annotate_rb/model_annotator/enum_annotation/annotation.rb +40 -0
  15. data/lib/annotate_rb/model_annotator/enum_annotation/annotation_builder.rb +38 -0
  16. data/lib/annotate_rb/model_annotator/enum_annotation/enum_component.rb +27 -0
  17. data/lib/annotate_rb/model_annotator/enum_annotation.rb +11 -0
  18. data/lib/annotate_rb/model_annotator/exclusion_constraint_annotation/annotation.rb +40 -0
  19. data/lib/annotate_rb/model_annotator/exclusion_constraint_annotation/annotation_builder.rb +38 -0
  20. data/lib/annotate_rb/model_annotator/exclusion_constraint_annotation/exclusion_constraint_component.rb +27 -0
  21. data/lib/annotate_rb/model_annotator/exclusion_constraint_annotation.rb +11 -0
  22. data/lib/annotate_rb/model_annotator/file_parser/annotation_finder.rb +75 -4
  23. data/lib/annotate_rb/model_annotator/file_parser/annotation_target.rb +31 -0
  24. data/lib/annotate_rb/model_annotator/file_parser/custom_parser.rb +11 -4
  25. data/lib/annotate_rb/model_annotator/file_parser/magic_comment.rb +32 -0
  26. data/lib/annotate_rb/model_annotator/file_parser/parsed_file.rb +5 -6
  27. data/lib/annotate_rb/model_annotator/file_parser/yml_parser.rb +43 -4
  28. data/lib/annotate_rb/model_annotator/file_parser.rb +2 -0
  29. data/lib/annotate_rb/model_annotator/foreign_key_annotation/foreign_key_component_builder.rb +6 -0
  30. data/lib/annotate_rb/model_annotator/index_annotation/annotation_builder.rb +29 -0
  31. data/lib/annotate_rb/model_annotator/index_annotation/index_component.rb +26 -4
  32. data/lib/annotate_rb/model_annotator/model_wrapper.rb +58 -2
  33. data/lib/annotate_rb/model_annotator/project_annotator.rb +2 -1
  34. data/lib/annotate_rb/model_annotator/single_file_annotation_remover.rb +1 -1
  35. data/lib/annotate_rb/model_annotator/single_file_annotator.rb +6 -6
  36. data/lib/annotate_rb/model_annotator/single_file_annotator_instruction.rb +3 -2
  37. data/lib/annotate_rb/model_annotator/unique_constraint_annotation/annotation.rb +40 -0
  38. data/lib/annotate_rb/model_annotator/unique_constraint_annotation/annotation_builder.rb +37 -0
  39. data/lib/annotate_rb/model_annotator/unique_constraint_annotation/unique_constraint_component.rb +27 -0
  40. data/lib/annotate_rb/model_annotator/unique_constraint_annotation.rb +11 -0
  41. data/lib/annotate_rb/model_annotator/zeitwerk_class_getter.rb +1 -1
  42. data/lib/annotate_rb/model_annotator.rb +3 -0
  43. data/lib/annotate_rb/options.rb +12 -2
  44. data/lib/annotate_rb/parser.rb +23 -6
  45. data/lib/annotate_rb/route_annotator/base_processor.rb +1 -1
  46. data/lib/annotate_rb/route_annotator/helper.rb +1 -1
  47. data/lib/annotate_rb/runner.rb +12 -6
  48. data/lib/annotate_rb/tasks/annotate_models_migrate.rake +8 -3
  49. data/lib/generators/annotate_rb/update_config/update_config_generator.rb +4 -1
  50. metadata +16 -2
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnnotateRb
4
+ module ModelAnnotator
5
+ module EnumAnnotation
6
+ class EnumComponent < Components::Base
7
+ attr_reader :name, :values, :max_size
8
+
9
+ def initialize(name, values, max_size)
10
+ @name = name
11
+ @values = values
12
+ @max_size = max_size
13
+ end
14
+
15
+ def to_default
16
+ # standard:disable Lint/FormatParameterMismatch
17
+ sprintf("# %-#{max_size}.#{max_size}s %s", name, values.join(", ")).rstrip
18
+ # standard:enable Lint/FormatParameterMismatch
19
+ end
20
+
21
+ def to_markdown
22
+ sprintf("# * `%s`: `%s`", name, values.join(", "))
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnnotateRb
4
+ module ModelAnnotator
5
+ module EnumAnnotation
6
+ autoload :AnnotationBuilder, "annotate_rb/model_annotator/enum_annotation/annotation_builder"
7
+ autoload :Annotation, "annotate_rb/model_annotator/enum_annotation/annotation"
8
+ autoload :EnumComponent, "annotate_rb/model_annotator/enum_annotation/enum_component"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnnotateRb
4
+ module ModelAnnotator
5
+ module ExclusionConstraintAnnotation
6
+ class Annotation
7
+ HEADER_TEXT = "Exclusion Constraints"
8
+
9
+ def initialize(constraints)
10
+ @constraints = constraints
11
+ end
12
+
13
+ def body
14
+ [
15
+ Components::BlankCommentLine.new,
16
+ Components::Header.new(HEADER_TEXT),
17
+ Components::BlankCommentLine.new,
18
+ *@constraints
19
+ ]
20
+ end
21
+
22
+ def to_markdown
23
+ body.map(&:to_markdown).join("\n")
24
+ end
25
+
26
+ def to_rdoc
27
+ body.map(&:to_rdoc).join("\n")
28
+ end
29
+
30
+ def to_yard
31
+ body.map(&:to_yard).join("\n")
32
+ end
33
+
34
+ def to_default
35
+ body.map(&:to_default).join("\n")
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnnotateRb
4
+ module ModelAnnotator
5
+ module ExclusionConstraintAnnotation
6
+ class AnnotationBuilder
7
+ def initialize(model, options)
8
+ @model = model
9
+ @options = options
10
+ end
11
+
12
+ def build
13
+ return Components::NilComponent.new if !@options[:show_exclusion_constraints]
14
+ return Components::NilComponent.new unless @model.connection.respond_to?(:supports_exclusion_constraints?) &&
15
+ @model.connection.supports_exclusion_constraints? && @model.connection.respond_to?(:exclusion_constraints)
16
+
17
+ exclusion_constraints = @model.connection.exclusion_constraints(@model.table_name)
18
+ return Components::NilComponent.new if exclusion_constraints.empty?
19
+
20
+ max_size = exclusion_constraints.map { |exclusion_constraint| exclusion_constraint.name.size }.max + 1
21
+
22
+ constraints = exclusion_constraints.sort_by(&:name).map do |exclusion_constraint|
23
+ details = "(#{exclusion_constraint.expression})"
24
+ details += " USING #{exclusion_constraint.using}" if exclusion_constraint.using
25
+ details += " WHERE (#{exclusion_constraint.where})" if exclusion_constraint.where
26
+ if exclusion_constraint.deferrable
27
+ details += " DEFERRABLE INITIALLY #{exclusion_constraint.deferrable.to_s.upcase}"
28
+ end
29
+
30
+ ExclusionConstraintComponent.new(exclusion_constraint.name, details, max_size)
31
+ end
32
+
33
+ _annotation = Annotation.new(constraints)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnnotateRb
4
+ module ModelAnnotator
5
+ module ExclusionConstraintAnnotation
6
+ class ExclusionConstraintComponent < Components::Base
7
+ attr_reader :name, :details, :max_size
8
+
9
+ def initialize(name, details, max_size)
10
+ @name = name
11
+ @details = details
12
+ @max_size = max_size
13
+ end
14
+
15
+ def to_default
16
+ # standard:disable Lint/FormatParameterMismatch
17
+ sprintf("# %-#{max_size}.#{max_size}s %s", name, details).rstrip
18
+ # standard:enable Lint/FormatParameterMismatch
19
+ end
20
+
21
+ def to_markdown
22
+ sprintf("# * `%s`: `%s`", name, details)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnnotateRb
4
+ module ModelAnnotator
5
+ module ExclusionConstraintAnnotation
6
+ autoload :AnnotationBuilder, "annotate_rb/model_annotator/exclusion_constraint_annotation/annotation_builder"
7
+ autoload :Annotation, "annotate_rb/model_annotator/exclusion_constraint_annotation/annotation"
8
+ autoload :ExclusionConstraintComponent, "annotate_rb/model_annotator/exclusion_constraint_annotation/exclusion_constraint_component"
9
+ end
10
+ end
11
+ end
@@ -8,6 +8,30 @@ module AnnotateRb
8
8
  COMPAT_PREFIX_MD = "## Schema Info"
9
9
  DEFAULT_ANNOTATION_ENDING = "#"
10
10
 
11
+ SCHEMA_HEADER_PREFIXES = [
12
+ COMPAT_PREFIX,
13
+ COMPAT_PREFIX_MD,
14
+ "Table name:",
15
+ "Database name:",
16
+ "Schema version:"
17
+ ].freeze
18
+
19
+ SCHEMA_HEADER_EXACT = [
20
+ IndexAnnotation::Annotation::HEADER_TEXT,
21
+ ForeignKeyAnnotation::Annotation::HEADER_TEXT,
22
+ CheckConstraintAnnotation::Annotation::HEADER_TEXT,
23
+ UniqueConstraintAnnotation::Annotation::HEADER_TEXT,
24
+ ExclusionConstraintAnnotation::Annotation::HEADER_TEXT,
25
+ EnumAnnotation::Annotation::HEADER_TEXT
26
+ ].freeze
27
+
28
+ # `format_markdown: true` renders each of the section headers above as
29
+ # "### <name>" instead of "<name>", so SCHEMA_HEADER_EXACT can't match it directly.
30
+ # There's no HEADER_TEXT constant for the columns table itself,
31
+ # so "Columns" is listed explicitly here.
32
+ MARKDOWN_HEADER_PREFIX = "### "
33
+ MARKDOWN_SECTION_HEADERS = (SCHEMA_HEADER_EXACT + ["Columns"]).freeze
34
+
11
35
  class MalformedAnnotation < StandardError; end
12
36
 
13
37
  class NoAnnotationFound < StandardError; end
@@ -70,10 +94,7 @@ module AnnotateRb
70
94
  end
71
95
  end
72
96
  else
73
- # Walk back until we find the end of the annotation comment block
74
- while ending > start && comments[ending].first != DEFAULT_ANNOTATION_ENDING
75
- ending -= 1
76
- end
97
+ ending = walk_forward_through_schema(comments, start, ending)
77
98
  end
78
99
 
79
100
  # We want .last because we want the line indexes
@@ -95,6 +116,56 @@ module AnnotateRb
95
116
  def annotated?
96
117
  @annotation_start && @annotation_end
97
118
  end
119
+
120
+ private
121
+
122
+ def walk_forward_through_schema(comments, start, block_end)
123
+ ending = start
124
+ while ending < block_end
125
+ break unless schema_like?(comments[ending + 1].first)
126
+ ending += 1
127
+ end
128
+ ending
129
+ end
130
+
131
+ # A line is part of the schema block if it matches one of the known
132
+ # header prefixes/exact texts, or if it looks like a tabular/list row
133
+ # of the annotation body:
134
+ #
135
+ # - Default/plain format: rows have >= 2 leading spaces after `#`
136
+ # (e.g. "# id :integer not null"); prose has at most one.
137
+ # - Markdown format (`format_markdown: true`) instead uses a single
138
+ # leading space after `#` for everything, so it needs its own
139
+ # detection: "### <name>" section headers (matched against
140
+ # MARKDOWN_SECTION_HEADERS with the prefix stripped), "|"-delimited
141
+ # table rows (header/divider/data), and "* `...`" bulleted list
142
+ # items for indexes/foreign keys/enums.
143
+ def schema_like?(comment)
144
+ return true if comment == DEFAULT_ANNOTATION_ENDING
145
+
146
+ text = comment.sub(/\A#\s?/, "")
147
+ return false if text.empty?
148
+
149
+ return true if SCHEMA_HEADER_PREFIXES.any? { |p| text.start_with?(p) }
150
+ return true if SCHEMA_HEADER_EXACT.include?(text)
151
+
152
+ # Markdown section headers, e.g. "### Columns", "### Indexes".
153
+ if text.start_with?(MARKDOWN_HEADER_PREFIX)
154
+ return MARKDOWN_SECTION_HEADERS.include?(text.delete_prefix(MARKDOWN_HEADER_PREFIX))
155
+ end
156
+
157
+ # Requires at least two | separators to identify Markdown tables.
158
+ # This prevents normal user comments containing a single | from being mistaken for a table.
159
+ return true if text.count("|") >= 2
160
+
161
+ # Markdown bulleted list items used for foreign keys/indexes/enums,
162
+ # e.g. "* `index_name`:" or the nested " * **`column`**". The
163
+ # backtick right after the bullet marker distinguishes these from
164
+ # an unrelated user comment that starts with a plain "* ".
165
+ return true if text.match?(/\A\s*\*\s+\*{0,2}`/)
166
+
167
+ comment.match?(/\A#\s{2,}\S/)
168
+ end
98
169
  end
99
170
  end
100
171
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnnotateRb
4
+ module ModelAnnotator
5
+ module FileParser
6
+ # When `model_class_name` is given, matches that class declaration directly
7
+ # so inner classes inside the model body cannot be mistaken for the target.
8
+ module AnnotationTarget
9
+ SKIP_NAMES = %w[require require_relative load].freeze
10
+
11
+ def self.find(parser, options, model_class_name: nil)
12
+ starts = parser.starts.reject { |entry| SKIP_NAMES.include?(entry.first) }
13
+ return nil if starts.empty?
14
+
15
+ return starts.first unless options[:nested_position] && parser.respond_to?(:type_map)
16
+
17
+ if model_class_name && parser.type_map[model_class_name] == :class
18
+ match = starts.find { |name, _line| name == model_class_name }
19
+ return match if match
20
+ end
21
+
22
+ class_entries = starts
23
+ .select { |name, _line| parser.type_map[name] == :class }
24
+ .uniq { |name, _line| name }
25
+
26
+ class_entries.last || starts.first
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -50,16 +50,23 @@ module AnnotateRb
50
50
  }
51
51
  end
52
52
 
53
+ def on_const(const)
54
+ @_last_const_lineno = lineno
55
+ super
56
+ end
57
+
53
58
  def on_const_ref(const)
54
- add_event(__method__, const, lineno)
55
- @block_starts << [const, lineno]
59
+ declaration_lineno = @_last_const_lineno || lineno
60
+ add_event(__method__, const, declaration_lineno)
61
+ @block_starts << [const, declaration_lineno]
56
62
  super
57
63
  end
58
64
 
59
65
  # Used for `class Foo::User`
60
66
  def on_const_path_ref(_left, const)
61
- add_event(__method__, const, lineno)
62
- @block_starts << [const, lineno]
67
+ declaration_lineno = @_last_const_lineno || lineno
68
+ add_event(__method__, const, declaration_lineno)
69
+ @block_starts << [const, declaration_lineno]
63
70
  super
64
71
  end
65
72
 
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnnotateRb
4
+ module ModelAnnotator
5
+ module FileParser
6
+ module MagicComment
7
+ DIRECTIVE_KEYS = %w[
8
+ encoding
9
+ coding
10
+ frozen_string_literal
11
+ warn_indent
12
+ shareable_constant_value
13
+ typed
14
+ rbs_inline
15
+ ].freeze
16
+
17
+ SIMPLE = /\A\s*#\s*(?<key>[A-Za-z][A-Za-z0-9_-]*)\s*:\s*\S/
18
+ EMACS = /\A\s*#\s*-\*-.*-\*-\s*\z/
19
+ VIM = /\A\s*#\s*vim:\s/
20
+
21
+ def self.match?(line)
22
+ if (m = SIMPLE.match(line))
23
+ key = m[:key].downcase.tr("-", "_")
24
+ return true if DIRECTIVE_KEYS.include?(key)
25
+ end
26
+
27
+ EMACS.match?(line) || VIM.match?(line)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -6,12 +6,13 @@ module AnnotateRb
6
6
  class ParsedFile
7
7
  SKIP_ANNOTATION_STRING = "# -*- SkipSchemaAnnotations"
8
8
 
9
- def initialize(file_content, new_annotations, parser_klass, options)
9
+ def initialize(file_content, new_annotations, parser_klass, options, model_class_name: nil)
10
10
  @file_content = file_content
11
11
  @file_lines = @file_content.lines
12
12
  @new_annotations = new_annotations
13
13
  @parser_klass = parser_klass
14
14
  @options = options
15
+ @model_class_name = model_class_name
15
16
  end
16
17
 
17
18
  def parse
@@ -44,7 +45,7 @@ module AnnotateRb
44
45
  annotation_start = @finder.annotation_start
45
46
  annotation_end = @finder.annotation_end
46
47
 
47
- if @file_lines[annotation_start - 1]&.strip&.empty?
48
+ if annotation_start > 0 && @file_lines[annotation_start - 1]&.strip&.empty?
48
49
  annotation_start -= 1
49
50
  has_leading_whitespace = true
50
51
  end
@@ -64,10 +65,8 @@ module AnnotateRb
64
65
  annotation_position = nil
65
66
 
66
67
  if has_annotations
67
- const_declaration = @file_parser.starts.first
68
-
69
- # If the file does not have any class or module declaration then const_declaration can be nil
70
- _const, line_number = const_declaration
68
+ target = AnnotationTarget.find(@file_parser, @options, model_class_name: @model_class_name)
69
+ _const, line_number = target if target
71
70
 
72
71
  if line_number
73
72
  annotation_position = if @finder.annotation_start < line_number
@@ -43,10 +43,20 @@ module AnnotateRb
43
43
  begin
44
44
  parser.parse(@input)
45
45
  rescue Psych::SyntaxError => _e
46
- # "Dynamic fixtures with ERB" exist in Rails, and will cause Psych.parser to error
47
- # This is a hacky solution to get around this and still have it parse
48
- erb_yml = ERB.new(@input).result
49
- parser.parse(erb_yml)
46
+ # "Dynamic fixtures with ERB" exist in Rails and cause Psych.parser to error.
47
+ #
48
+ # We deliberately do not evaluate the ERB and read line numbers off the
49
+ # result: evaluating runs arbitrary code, and the line numbers from the
50
+ # evaluated output do not map back to the original file (ERB tags spanning
51
+ # multiple lines shift the offsets), which would place annotations inside
52
+ # an ERB tag. Instead we derive the content bounds straight from the
53
+ # original lines so annotations land around the ERB body.
54
+ #
55
+ # Only fall back for actual ERB fixtures. Genuinely malformed YAML
56
+ # (no ERB tags) should keep surfacing the parse error rather than
57
+ # being silently annotated.
58
+ raise unless erb_fixture?
59
+ return record_erb_positions
50
60
  end
51
61
 
52
62
  stream = parser.handler.root
@@ -68,6 +78,35 @@ module AnnotateRb
68
78
  @ends << [nil, stream.end_line]
69
79
  end
70
80
  end
81
+
82
+ # Locates the content bounds of an ERB fixture directly from the original
83
+ # lines, treating the ERB/YAML body as the doc. The start is the first
84
+ # non-blank, non-comment line so annotations are written above the ERB
85
+ # block (and after any leading comments), never inside a tag.
86
+ def record_erb_positions
87
+ lines = @input.split($/)
88
+ content_start = lines.index { |line| content_line?(line) }
89
+
90
+ if content_start.nil?
91
+ @starts << [nil, 0]
92
+ @ends << [nil, 0]
93
+ else
94
+ content_end = lines.rindex { |line| content_line?(line) }
95
+ @starts << [nil, content_start]
96
+ @ends << [nil, content_end + 1]
97
+ end
98
+ end
99
+
100
+ def content_line?(line)
101
+ stripped = line.strip
102
+ !stripped.empty? && !stripped.start_with?("#")
103
+ end
104
+
105
+ # True when the input contains an ERB tag, i.e. it is a dynamic fixture
106
+ # rather than plain (possibly malformed) YAML.
107
+ def erb_fixture?
108
+ @input.match?(/<%.*?%>/m)
109
+ end
71
110
  end
72
111
  end
73
112
  end
@@ -4,7 +4,9 @@ module AnnotateRb
4
4
  module ModelAnnotator
5
5
  module FileParser
6
6
  autoload :AnnotationFinder, "annotate_rb/model_annotator/file_parser/annotation_finder"
7
+ autoload :AnnotationTarget, "annotate_rb/model_annotator/file_parser/annotation_target"
7
8
  autoload :CustomParser, "annotate_rb/model_annotator/file_parser/custom_parser"
9
+ autoload :MagicComment, "annotate_rb/model_annotator/file_parser/magic_comment"
8
10
  autoload :ParsedFile, "annotate_rb/model_annotator/file_parser/parsed_file"
9
11
  autoload :ParsedFileResult, "annotate_rb/model_annotator/file_parser/parsed_file_result"
10
12
  autoload :YmlParser, "annotate_rb/model_annotator/file_parser/yml_parser"
@@ -32,6 +32,12 @@ module AnnotateRb
32
32
  constraints_info = ""
33
33
  constraints_info += "ON DELETE => #{foreign_key.on_delete} " if foreign_key.on_delete
34
34
  constraints_info += "ON UPDATE => #{foreign_key.on_update} " if foreign_key.on_update
35
+ if foreign_key.respond_to?(:deferrable) && foreign_key.deferrable
36
+ # Rails 7.0's extract_foreign_key_deferrable returns `true` for the initially-immediate case;
37
+ # 7.1+ normalized this to `:immediate`. Map `true` back to IMMEDIATE so we don't emit "INITIALLY TRUE".
38
+ initially = (foreign_key.deferrable == true) ? "IMMEDIATE" : foreign_key.deferrable.to_s.upcase
39
+ constraints_info += "DEFERRABLE INITIALLY #{initially} "
40
+ end
35
41
  constraints_info.strip
36
42
  end
37
43
  end
@@ -15,6 +15,9 @@ module AnnotateRb
15
15
  indexes = @model.retrieve_indexes_from_table
16
16
  return Components::NilComponent.new if indexes.empty?
17
17
 
18
+ indexes = reject_constraint_backed_indexes(indexes)
19
+ return Components::NilComponent.new if indexes.empty?
20
+
18
21
  max_size = indexes.map { |index| index.name.size }.max + 1
19
22
 
20
23
  indexes = indexes.sort_by(&:name).map do |index|
@@ -23,6 +26,32 @@ module AnnotateRb
23
26
 
24
27
  _annotation = Annotation.new(indexes)
25
28
  end
29
+
30
+ private
31
+
32
+ # Mirrors ActiveRecord's schema_dumper#indexes_in_create: PostgreSQL's
33
+ # unique and exclusion constraints are backed by indexes with the same
34
+ # name, so those show up in `connection.indexes` too. Drop them here so
35
+ # they only appear under their dedicated sections.
36
+ def reject_constraint_backed_indexes(indexes)
37
+ connection = @model.connection
38
+
39
+ if connection.respond_to?(:supports_exclusion_constraints?) &&
40
+ connection.supports_exclusion_constraints? &&
41
+ connection.respond_to?(:exclusion_constraints)
42
+ excl_names = connection.exclusion_constraints(@model.table_name).map(&:name)
43
+ indexes = indexes.reject { |index| excl_names.include?(index.name) }
44
+ end
45
+
46
+ if connection.respond_to?(:supports_unique_constraints?) &&
47
+ connection.supports_unique_constraints? &&
48
+ connection.respond_to?(:unique_constraints)
49
+ unique_names = connection.unique_constraints(@model.table_name).map(&:name)
50
+ indexes = indexes.reject { |index| unique_names.include?(index.name) }
51
+ end
52
+
53
+ indexes
54
+ end
26
55
  end
27
56
  end
28
57
  end
@@ -45,16 +45,27 @@ module AnnotateRb
45
45
  end
46
46
  end
47
47
 
48
+ comment_info = ""
49
+ if options[:show_indexes_comments]
50
+ value = index.try(:comment).try(:to_s)
51
+ comment_info = if value.present?
52
+ " COMMENT #{value}"
53
+ else
54
+ ""
55
+ end
56
+ end
57
+
48
58
  # standard:disable Lint/FormatParameterMismatch
49
59
  sprintf(
50
- "# %-#{max_size}.#{max_size}s %s%s%s%s%s%s",
60
+ "# %-#{max_size}.#{max_size}s %s%s%s%s%s%s%s",
51
61
  index.name,
52
62
  "(#{columns_info.join(",")})",
53
63
  include_info,
54
64
  unique_info,
55
65
  nulls_not_distinct_info,
56
66
  where_info,
57
- using_info
67
+ using_info,
68
+ comment_info
58
69
  ).rstrip
59
70
  # standard:enable Lint/FormatParameterMismatch
60
71
  end
@@ -92,13 +103,24 @@ module AnnotateRb
92
103
  end
93
104
  end
94
105
 
106
+ comment_info = ""
107
+ if options[:show_indexes_comments]
108
+ value = index.try(:comment).try(:to_s)
109
+ comment_info = if value.present?
110
+ " _comment_ #{value}"
111
+ else
112
+ ""
113
+ end
114
+ end
115
+
95
116
  details = sprintf(
96
- "%s%s%s%s%s",
117
+ "%s%s%s%s%s%s",
97
118
  include_info,
98
119
  unique_info,
99
120
  nulls_not_distinct_info,
100
121
  where_info,
101
- using_info
122
+ using_info,
123
+ comment_info
102
124
  ).strip
103
125
  details = " (#{details})" unless details.blank?
104
126
 
@@ -68,8 +68,20 @@ module AnnotateRb
68
68
  connection.table_comment(@klass.table_name).present?
69
69
  end
70
70
 
71
+ # Returns column defaults for annotations.
72
+ #
73
+ # `Model#column_defaults` reflects `attribute :foo, default: X` overrides,
74
+ # which would incorrectly show the Ruby-side default in annotations
75
+ # instead of the DB schema default. To preserve model-level decorations
76
+ # such as `TimeZoneConverter` on datetime columns, we start from
77
+ # `column_defaults` and only substitute the DB schema value when a
78
+ # difference indicates an attribute-level override.
71
79
  def column_defaults
72
- @klass.column_defaults
80
+ @column_defaults ||= @klass.column_defaults.each_with_object({}) do |(name, value), result|
81
+ column = @klass.columns_hash[name]
82
+ schema_value = schema_default_for(column)
83
+ result[name] = (value == schema_value) ? value : schema_value
84
+ end
73
85
  end
74
86
 
75
87
  # Add columns managed by the globalize gem if this gem is being used.
@@ -219,6 +231,23 @@ module AnnotateRb
219
231
  ]
220
232
  end
221
233
 
234
+ def enum_types
235
+ @enum_types ||=
236
+ if connection.respond_to?(:enum_types)
237
+ begin
238
+ # enum values may be a String or an Array depending on the Rails version.
239
+ # See: https://github.com/rails/rails/pull/54141
240
+ connection.enum_types.map do |name, values|
241
+ [name, values.is_a?(Array) ? values : values.to_s.split(",")]
242
+ end
243
+ rescue ActiveRecord::StatementInvalid
244
+ []
245
+ end
246
+ else
247
+ []
248
+ end
249
+ end
250
+
222
251
  def migration_version
223
252
  return 0 unless @options[:include_version]
224
253
 
@@ -229,7 +258,14 @@ module AnnotateRb
229
258
 
230
259
  if @options.get_state(cache_key).nil?
231
260
  migration_version = begin
232
- connection.migration_context.current_version
261
+ # Rails 7.1+ moved migration_context from ConnectionAdapter to ConnectionPool.
262
+ # ConnectionAdapter#migration_context was removed in Rails 7.2.
263
+ # See: https://github.com/rails/rails/pull/51162
264
+ if connection.pool.respond_to?(:migration_context)
265
+ connection.pool.migration_context.current_version
266
+ else
267
+ connection.migration_context.current_version
268
+ end
233
269
  rescue
234
270
  0
235
271
  end
@@ -239,6 +275,26 @@ module AnnotateRb
239
275
 
240
276
  @options.get_state(cache_key)
241
277
  end
278
+
279
+ private
280
+
281
+ def schema_default_for(column)
282
+ return nil if column.nil? || column.default.nil? || column.default_function
283
+ cast_type_for(column).deserialize(column.default)
284
+ end
285
+
286
+ # Rails post-8.1 exposes `Column#cast_type` directly; Rails 8.1 introduced
287
+ # the transitional `Column#fetch_cast_type(connection)`; older versions
288
+ # required `connection.lookup_cast_type_from_column(column)`.
289
+ def cast_type_for(column)
290
+ if column.respond_to?(:cast_type)
291
+ column.cast_type
292
+ elsif column.respond_to?(:fetch_cast_type)
293
+ column.fetch_cast_type(connection)
294
+ else
295
+ connection.lookup_cast_type_from_column(column)
296
+ end
297
+ end
242
298
  end
243
299
  end
244
300
  end